public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [RFC proxmox-perl-rs/qemu-server/qemu-server-rs v2 0/9] pci-handling rewrite (part 1/2/3)
@ 2026-10-15  8:10 Dominik Csapak
  2026-10-15  8:10 ` [PATCH pve-qemu-server-rs v2 1/9] add pve-qemu-server-pci crate for guest PCI address generation Dominik Csapak
                   ` (8 more replies)
  0 siblings, 9 replies; 12+ messages in thread
From: Dominik Csapak @ 2026-10-15  8:10 UTC (permalink / raw)
  To: pve-devel

# Motivation

We want to extend the PCI layout mechanism to be easily extendable, especially
for NUMA awareness and replicating host-topology (e.g. see bug #7283 [0]).

This is necessary for some performance gains in guests, especially with
high-end hardware where users pay extra for higher performance.

# The Problem

PCI addresses (and hardware layout in general) must be fixed for our qemu
guests, since this is part of the machine state and must be stable for
live-migration suspend/resume, etc.

Currently, these addresses are hardcoded in perl hashes, with a long history of
devs (me included) piling new addresses on top, using whatever address was
free.

This led to a very convoluted and scattered assignment list, without any
underlying structure, which makes it harder the more new devices are added.
(Using a wrong address only surfaces when running some tests or starting a
guest with it)

# My Proposed Solution

So to fix the current situation I propose a plan in multiple phases:

## Phase 1 - untangling perl code to rust (this series)

To extend/switch between multiple PCI layouts, we first must untangle the
current situation and make it easier to see what is actually used.

I did this in rust, by using the type system to represent the PCI layout, and
inferring the addresses from the layout, not the other way around.

That makes it easier to see where there is actually free space and where we can
add new things.

It also highlights how messy the current layout is. (See the legacy layout in
the `pve-qemu-server-pci` crate)

I also introduced here a generic `HardwareConfig` struct that's intended to
hold information useful to more call sites in contrast to passing a lot of
arguments everywhere, similar to the Cfg2Cmd class, but usable in the rust
codebase.

## Phase 2 - extending pci layouts (this series)

This introduces a new layout ("grouped") that can be used in an opt-in manner,
and is vastly more logically constructed, which makes it easy to extend and
reason about.

This part entails restructuring the addr calls in qemu-server's perl code, so
that there is only a single entry point ("address" for example) instead of
splitting between 'print_pci_addr' and 'print_pcie_addr'.

This is folded into the commit to using the rust bindings, since it does not
make sense to recreate the old interface in rust, just to replace it with
the next patches anyway.

This now also creates the commandline for the q35 devices instead
of using 'readconfig' (guarded behind a new machine version).

## Phase 3 - more perl to rust code port (this series)

The HardwareConfig also contains a version comparison, so we can widely reuse
it in many methods, since we need it for the 'addr' interface anyway.

These changes are also folded for the same reason as above.

## Phase 4 - Host and NUMA layouts (the next part)

With all the pieces in place, we can then easily extend the new layout by PCI
switches that are needed for assigning to NUMA nodes and replicating the host
topology for passed through devices.

# Notes

* pve-qemu-server-rs is a new git repo, but the crate in it could
  easily be integrated somewhere else. Having this as a separate repo
  could replace 'qemu-server' at one point though.
* This is an RFC, so comments about the general design/plan are desired.
* Names and crate placement are not fixed, I know that some names I chose are
  not optimal, if you do have better names/places, please suggest them.
* I based this on my recent qemu-server hotplug v3 series [1], so keep that in
  mind when testing/applying
* Opted to do this in rust for now, since I think that is the best way forward.
  If the consensus is to do the layouting changes in perl, or keep some parts in
  perl, that's fine with me too and I'll change my efforts accordingly


changes from rfc v1:
* rebased on the hotplug v3[1] and cleanup[2] series
* split out the qemu-server cleanups/fixes into a separate series[2]
* this now also includes phase 2 and 3 of the plan

- pve-qemu-server-rs:
* renamed `Machine` to `HardwareConfig` and extended it (mainboard,
  layout, hostpcie devices, windows version)
* version_at_least now records the required +pveX version like the
  version guard in qemu-server, and treats a missing pve version as
  +pve0
* use a single `address` entry point instead of print_pci_addr and
  print_pcie_addr, the win7 quirk is now handled internally
* replaced get_pci_bridges with pci_bus_devices, which also returns the
  hostpcie root ports and the q35 chipset devices (from 11.1+pve1 on)
* add the root port on function 0 of used slots, otherwise the guest
  does not see the other functions
* renamed the 'v2' layout to 'grouped', removed the gaps between the
  bridges and moved the root ports
* take the device limits from pve-api-types instead of hardcoding them
* renamed some types and added a `LegacyRootPort` bus variant
* more tests

- proxmox-perl-rs:
* replaced the free functions and the `Machine` class with a single
  `PVE::RS::PCI::HardwareConfig` class

- qemu-server:
* removed the print_pci_addr/print_pcie_addr wrappers, construct the
  HardwareConfig once and pass it around instead of the arch
* root ports now come from the rust side, which fixes the invisible
  hostpcie devices without a root port on function 0
* use the HardwareConfig for the machine version checks
* generate the q35 chipset devices with 11.1+pve1 instead of using
  the readconfig file
* add the 'pci-layout' machine option to opt-in to the grouped layout

0: https://bugzilla.proxmox.com/show_bug.cgi?id=7283
1: https://lore.proxmox.com/pve-devel/20261007142145.984970-1-d.csapak@proxmox.com/
2: https://lore.proxmox.com/pve-devel/20261015075428.572882-1-d.csapak@proxmox.com/


pve-qemu-server-rs:

Dominik Csapak (4):
  add pve-qemu-server-pci crate for guest PCI address generation
  pci: legacy: generate pci bridges
  pci: legacy: generate q35 chipset devices and hostpcie root ports
  pci: layout: add grouped PCI and PCIe layouts


proxmox-perl-rs:

Dominik Csapak (1):
  pve: add bindings for `pve-qemu-server-pci` crate

 pve-rs/Cargo.toml                          |   1 +
 pve-rs/Makefile                            |   1 +
 pve-rs/src/bindings/mod.rs                 |   3 +
 pve-rs/src/bindings/pci/hardware_config.rs | 150 +++++++++++++++++++++
 pve-rs/src/bindings/pci/mod.rs             |   2 +
 5 files changed, 157 insertions(+)
 create mode 100644 pve-rs/src/bindings/pci/hardware_config.rs
 create mode 100644 pve-rs/src/bindings/pci/mod.rs


qemu-server:

Dominik Csapak (4):
  pci: use PVE::RS::PCI bindings for addresses and bus devices
  tree-wide: use HardwareConfig for the machine version checks
  cfg2cmd: q35: generate chipset devices with machine version 11.1+pve1
  machine: add 'pci-layout' option

 src/PVE/API2/Qemu.pm                          |   9 +-
 src/PVE/QemuServer.pm                         | 250 +++++++------
 src/PVE/QemuServer/Drive.pm                   |   7 +-
 src/PVE/QemuServer/DriveDevice.pm             |  12 +-
 src/PVE/QemuServer/Machine.pm                 |  16 +
 src/PVE/QemuServer/PCI.pm                     | 340 ++----------------
 src/PVE/QemuServer/RNG.pm                     |   6 +-
 src/PVE/QemuServer/USB.pm                     |  58 +--
 src/PVE/QemuServer/Virtiofs.pm                |   7 +-
 src/test/Makefile                             |   5 +-
 src/test/TestsCommon/CommandLineMocks.pm      |  27 ++
 .../cfg2cmd/efi-secboot-and-tpm-q35.conf.cmd  |  21 +-
 src/test/cfg2cmd/q35-grouped-layout.conf      |  17 +
 src/test/cfg2cmd/q35-grouped-layout.conf.cmd  |  47 +++
 .../cfg2cmd/q35-grouped-virtiofs-viommu.conf  |  14 +
 .../q35-grouped-virtiofs-viommu.conf.cmd      |  46 +++
 src/test/cfg2cmd/q35-ide.conf.cmd             |  21 +-
 .../cfg2cmd/q35-linux-hostpci-11.1+pve1.conf  |  21 ++
 .../q35-linux-hostpci-11.1+pve1.conf.cmd      |  61 ++++
 .../q35-linux-hostpci-driver-keep.conf.cmd    |  25 +-
 .../q35-linux-hostpci-mapping.conf.cmd        |  21 +-
 .../q35-linux-hostpci-multifunction.conf.cmd  |  21 +-
 src/test/cfg2cmd/q35-linux-hostpci-wrong.conf |  16 +
 .../cfg2cmd/q35-linux-hostpci-wrong.conf.cmd  |  41 +++
 ...q35-linux-hostpci-x-pci-overrides.conf.cmd |  21 +-
 src/test/cfg2cmd/q35-linux-hostpci.conf.cmd   |  25 +-
 src/test/cfg2cmd/q35-linux-ivshmem.conf.cmd   |  21 +-
 src/test/cfg2cmd/q35-scsihw2.conf.cmd         |  21 +-
 src/test/cfg2cmd/q35-simple.conf.cmd          |  21 +-
 .../cfg2cmd/q35-viommu-intel-aw-bits.conf.cmd |  21 +-
 .../q35-viommu-intel-guest-phys-bits.conf.cmd |  21 +-
 src/test/cfg2cmd/q35-viommu-intel.conf.cmd    |  21 +-
 .../q35-viommu-virtio-aw-bits.conf.cmd        |  21 +-
 src/test/cfg2cmd/q35-viommu-virtio.conf.cmd   |  21 +-
 src/test/cfg2cmd/q35-win10-hostpci.conf.cmd   |   2 +-
 .../simple-grouped-virtiofs-viommu.conf       |  16 +
 .../simple-grouped-virtiofs-viommu.conf.cmd   |  45 +++
 src/test/cfg2cmd/simple-grouped.conf          |  15 +
 src/test/cfg2cmd/simple-grouped.conf.cmd      |  41 +++
 src/test/cfg2cmd/spice-win-grouped.conf       |  14 +
 src/test/cfg2cmd/spice-win-grouped.conf.cmd   |  48 +++
 src/test/cfg2cmd/virtiofs-viommu.conf         |  16 +
 src/test/cfg2cmd/virtiofs-viommu.conf.cmd     |  40 +++
 src/test/run_pci_addr_checks.pl               | 141 --------
 44 files changed, 1039 insertions(+), 662 deletions(-)
 create mode 100644 src/test/cfg2cmd/q35-grouped-layout.conf
 create mode 100644 src/test/cfg2cmd/q35-grouped-layout.conf.cmd
 create mode 100644 src/test/cfg2cmd/q35-grouped-virtiofs-viommu.conf
 create mode 100644 src/test/cfg2cmd/q35-grouped-virtiofs-viommu.conf.cmd
 create mode 100644 src/test/cfg2cmd/q35-linux-hostpci-11.1+pve1.conf
 create mode 100644 src/test/cfg2cmd/q35-linux-hostpci-11.1+pve1.conf.cmd
 create mode 100644 src/test/cfg2cmd/q35-linux-hostpci-wrong.conf
 create mode 100644 src/test/cfg2cmd/q35-linux-hostpci-wrong.conf.cmd
 create mode 100644 src/test/cfg2cmd/simple-grouped-virtiofs-viommu.conf
 create mode 100644 src/test/cfg2cmd/simple-grouped-virtiofs-viommu.conf.cmd
 create mode 100644 src/test/cfg2cmd/simple-grouped.conf
 create mode 100644 src/test/cfg2cmd/simple-grouped.conf.cmd
 create mode 100644 src/test/cfg2cmd/spice-win-grouped.conf
 create mode 100644 src/test/cfg2cmd/spice-win-grouped.conf.cmd
 create mode 100644 src/test/cfg2cmd/virtiofs-viommu.conf
 create mode 100644 src/test/cfg2cmd/virtiofs-viommu.conf.cmd
 delete mode 100755 src/test/run_pci_addr_checks.pl


Summary over all repositories:
  49 files changed, 1196 insertions(+), 662 deletions(-)

-- 
Generated by murpp 0.11.0




^ permalink raw reply	[flat|nested] 12+ messages in thread

* [PATCH pve-qemu-server-rs v2 1/9] add pve-qemu-server-pci crate for guest PCI address generation
  2026-10-15  8:10 [RFC proxmox-perl-rs/qemu-server/qemu-server-rs v2 0/9] pci-handling rewrite (part 1/2/3) Dominik Csapak
@ 2026-10-15  8:10 ` Dominik Csapak
  2026-10-15 12:16   ` Wolfgang Bumiller
  2026-10-15  8:10 ` [PATCH pve-qemu-server-rs v2 2/9] pci: legacy: generate pci bridges Dominik Csapak
                   ` (7 subsequent siblings)
  8 siblings, 1 reply; 12+ messages in thread
From: Dominik Csapak @ 2026-10-15  8:10 UTC (permalink / raw)
  To: pve-devel

Since the hardware layout, including PCI addresses, are part of the
guest's ABI, the assignment has to stay stable for the whole lifetime
of a VM. The Perl implementation in PVE::QemuServer::PCI encodes this as
two flat hashes of hardcoded addresses, which is hard to extend without
accidentally shifting an existing entry.

Describe the assignment as a layout instead: a tree of buses, slots and
functions that says what may sit where, which addresses are reserved for
something outside of our control, and which are still free. Looking up a
device then means walking that tree, and adding a new device means
filling in a free slot rather than picking a number by hand.

The layouts added here reproduce the current Perl address maps exactly,
including the quirks for legacy IGD passthrough, virtio-scsi-single and
the Windows 7 PCIe workaround. The tests cover every id the Perl maps
contain (except win7's hostpciXbus0, since that will change in perl
anyway).

Lookups take a `HardwareConfig`, which only contains the parts of the
guest config that influence the layout and its quirks, so callers can
build it from whatever config representation they have.

Also adds the cargo workspace and the build tooling the other Proxmox
Rust repositories use to build Debian packages.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 .cargo/config.toml                           |   5 +
 .gitignore                                   |  13 +
 Cargo.toml                                   |  29 +
 Makefile                                     | 153 +++++
 build.sh                                     |  40 ++
 bump.sh                                      |  44 ++
 pve-qemu-server-pci/Cargo.toml               |  15 +
 pve-qemu-server-pci/debian/changelog         |   5 +
 pve-qemu-server-pci/debian/control           |  36 ++
 pve-qemu-server-pci/debian/copyright         |  18 +
 pve-qemu-server-pci/debian/debcargo.toml     |   7 +
 pve-qemu-server-pci/src/constants.rs         |   7 +
 pve-qemu-server-pci/src/error.rs             |  24 +
 pve-qemu-server-pci/src/hardware_config.rs   | 156 +++++
 pve-qemu-server-pci/src/layout/legacy.rs     | 628 +++++++++++++++++++
 pve-qemu-server-pci/src/layout/mod.rs        |  48 ++
 pve-qemu-server-pci/src/lib.rs               |  33 +
 pve-qemu-server-pci/src/types/bus.rs         | 198 ++++++
 pve-qemu-server-pci/src/types/device.rs      | 173 +++++
 pve-qemu-server-pci/src/types/layout.rs      | 329 ++++++++++
 pve-qemu-server-pci/src/types/mod.rs         |  11 +
 pve-qemu-server-pci/src/types/pci_address.rs |  36 ++
 rustfmt.toml                                 |   2 +
 23 files changed, 2010 insertions(+)
 create mode 100644 .cargo/config.toml
 create mode 100644 .gitignore
 create mode 100644 Cargo.toml
 create mode 100644 Makefile
 create mode 100755 build.sh
 create mode 100755 bump.sh
 create mode 100644 pve-qemu-server-pci/Cargo.toml
 create mode 100644 pve-qemu-server-pci/debian/changelog
 create mode 100644 pve-qemu-server-pci/debian/control
 create mode 100644 pve-qemu-server-pci/debian/copyright
 create mode 100644 pve-qemu-server-pci/debian/debcargo.toml
 create mode 100644 pve-qemu-server-pci/src/constants.rs
 create mode 100644 pve-qemu-server-pci/src/error.rs
 create mode 100644 pve-qemu-server-pci/src/hardware_config.rs
 create mode 100644 pve-qemu-server-pci/src/layout/legacy.rs
 create mode 100644 pve-qemu-server-pci/src/layout/mod.rs
 create mode 100644 pve-qemu-server-pci/src/lib.rs
 create mode 100644 pve-qemu-server-pci/src/types/bus.rs
 create mode 100644 pve-qemu-server-pci/src/types/device.rs
 create mode 100644 pve-qemu-server-pci/src/types/layout.rs
 create mode 100644 pve-qemu-server-pci/src/types/mod.rs
 create mode 100644 pve-qemu-server-pci/src/types/pci_address.rs
 create mode 100644 rustfmt.toml

diff --git a/.cargo/config.toml b/.cargo/config.toml
new file mode 100644
index 0000000..3b5b6e4
--- /dev/null
+++ b/.cargo/config.toml
@@ -0,0 +1,5 @@
+[source]
+[source.debian-packages]
+directory = "/usr/share/cargo/registry"
+[source.crates-io]
+replace-with = "debian-packages"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..dbdfcdc
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,13 @@
+/target
+/*/target
+Cargo.lock
+**/*.rs.bk
+*.buildinfo
+*.changes
+*.deb
+*.dsc
+*.tar.?z
+*.tar.zst
+/build
+/*-deb
+/*-dsc
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..71bc8e5
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,29 @@
+[workspace]
+members = [
+    "pve-qemu-server-pci"
+]
+exclude = [
+    "build",
+]
+resolver = "3"
+
+[workspace.package]
+authors = ["Proxmox Support Team <support@proxmox.com>"]
+edition = "2024"
+license = "AGPL-3"
+repository = "https://git.proxmox.com/?p=pve-qemu-server-rs.git"
+homepage = "https://proxmox.com"
+exclude = [ "debian" ]
+rust-version = "1.94"
+
+[workspace.dependencies]
+# any features enabled here are enabled on all members using 'workspace = true'!
+
+# external dependencies
+anyhow = "1.0"
+log = "0.4"
+strum = { version = "0.26", features = ["derive"] }
+
+# proxmox dependencies
+pve-api-types = "8.1"
+
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..f89bb73
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,153 @@
+# Shortcut for common operations:
+
+CRATES != echo pve-*/Cargo.toml | sed -e 's|/Cargo.toml||g'
+
+# By default we just run checks:
+.PHONY: all
+all: check
+
+.PHONY: deb
+deb: $(foreach c,$(CRATES), $c-deb)
+	echo $(foreach c,$(CRATES), $c-deb)
+	lintian build/*.deb
+
+.PHONY: dsc
+dsc: $(foreach c,$(CRATES), $c-dsc)
+	echo $(foreach c,$(CRATES), $c-dsc)
+	lintian build/*.dsc
+
+.PHONY: autopkgtest
+autopkgtest: $(foreach c,$(CRATES), $c-autopkgtest)
+
+.PHONY: dinstall
+dinstall:
+	$(MAKE) clean
+	$(MAKE) deb
+	sudo -k dpkg -i build/librust-*.deb
+
+%-deb:
+	./build.sh $*
+	touch $@
+
+proxmox-oci-deb:
+	TEST_CMD="fakeroot cargo test --all-features --all-targets --release" ./build.sh proxmox-oci
+	touch $@
+
+%-dsc:
+	BUILDCMD='dpkg-buildpackage -S -us -uc -d' NOTEST=1 ./build.sh $*
+	touch $@
+
+%-autopkgtest:
+	autopkgtest build/$* build/*.deb -- null
+	touch $@
+
+.PHONY: list-packages
+list-packages:
+	@for p in $(CRATES); do \
+		echo "librust-$$p-dev"; \
+	done
+
+.PHONY: check
+check:
+	cargo test
+
+# Run the api-test server, serving the api-test/www/ subdir as 'www' dir over
+# http:
+.PHONY: apitest
+apitest:
+	cargo run -p api-test -- api-test/www/
+
+# Prints a diff between the current code and the one rustfmt would produce
+.PHONY: fmt
+fmt:
+	cargo +nightly fmt -- --check
+
+# Doc without dependencies
+.PHONY: doc
+doc:
+	cargo doc --no-deps
+
+.PHONY: clean
+clean:
+	cargo clean
+	rm -rf build/
+	rm -f -- *-deb *-dsc *-autopkgtest *.build *.buildinfo *.changes
+
+.PHONY: update
+update:
+	cargo update
+
+%-upload: %-deb
+	cd build; \
+	    dcmd --deb rust-$*_*.changes \
+	    | grep -v '.changes$$' \
+	    | tar -cf "$@.tar" -T-; \
+	    cat "$@.tar" | ssh -X repoman@repo.proxmox.com upload --product devel --dist trixie
+
+%-install:
+	rm -rf build/install/$*
+	mkdir -p build/install/$*
+	BUILDDIR=build/install/$* BUILDCMD=/usr/bin/true NOCONTROL=1 ./build.sh "$*" || true
+	version="$$(dpkg-parsechangelog -l $*/debian/changelog -SVersion | sed -e 's/-.*//')"; \
+	  install -m755 -Dd "$(DESTDIR)/usr/share/cargo/registry/$*-$${version}"; \
+	  rm -rf "$(DESTDIR)/usr/share/cargo/registry/$*-$${version}"; \
+	  mv "build/install/$*/$*" \
+	    "$(DESTDIR)/usr/share/cargo/registry/$*-$${version}"; \
+	  mv "$(DESTDIR)/usr/share/cargo/registry/$*-$${version}/debian/cargo-checksum.json" \
+	    "$(DESTDIR)/usr/share/cargo/registry/$*-$${version}/.cargo-checksum.json"; \
+	  rm -rf "$(DESTDIR)/usr/share/cargo/registry/$*-$${version}/debian" \
+
+.PHONY: install
+install: $(foreach c,$(CRATES), $c-install)
+
+%-install-overlay: %-install
+	version="$$(dpkg-parsechangelog -l $*/debian/changelog -SVersion | sed -e 's/-.*//')"; \
+	  setfattr -n trusted.overlay.opaque -v y \
+	    "$(DESTDIR)/usr/share/cargo/registry/$*-$${version}"
+	install -m755 -Dd $(DESTDIR)/usr/lib/extension-release.d
+	echo 'ID=_any' >$(DESTDIR)/usr/lib/extension-release.d/extension-release.$*
+
+.PHONY: install-overlay
+install-overlay: $(foreach c,$(CRATES), $c-install-overlay)
+
+# To make sure a sysext *replaces* a crate, rather than "merging" with it, we
+# need to be able to set the 'trusted.overlay.opaque' xattr. Since we cannot do
+# this as a user, we utilize `fakeroot` which keeps track of this for us, and
+# turn the final directory into an 'erofs' file system image.
+#
+# The reason is that if a crate gets changed like this:
+#
+#     old:
+#         src/foo.rs
+#     new:
+#         src/foo/mod.rs
+#
+# if its /usr/share/cargo/registry/$crate-$version directory was not marked as
+# "opaque", the merged file system would end up with both
+#
+#         src/foo.rs
+#         src/foo/mod.rs
+#
+# together.
+#
+# See https://docs.kernel.org/filesystems/overlayfs.html
+%-sysext:
+	fakeroot $(MAKE) $*-sysext-do
+%-sysext-do:
+	rm -f extensions/$*.raw
+	rm -rf build/sysext/$*
+	rm -rf build/install/$*
+	$(MAKE) DESTDIR=build/sysext/$* $*-install-overlay
+	mkdir -p extensions
+	mkfs.erofs extensions/$*.raw build/sysext/$*
+
+sysext:
+	fakeroot $(MAKE) sysext-do
+sysext-do:
+	rm -f extensions/proxmox-workspace.raw
+	[ -n "$(NOCLEAN)" ] || rm -rf build/sysext/workspace
+	$(MAKE) DESTDIR=build/sysext/workspace $(foreach c,$(CRATES), $c-install)
+	install -m755 -Dd build/sysext/workspace/usr/lib/extension-release.d
+	echo 'ID=_any' >build/sysext/workspace/usr/lib/extension-release.d/extension-release.proxmox-workspace
+	mkdir -p extensions
+	mkfs.erofs extensions/proxmox-workspace.raw build/sysext/workspace
diff --git a/build.sh b/build.sh
new file mode 100755
index 0000000..3c2293b
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,40 @@
+#!/bin/sh
+
+set -e
+
+export CARGO=/usr/bin/cargo
+export RUSTC=/usr/bin/rustc
+
+CRATE=$1
+BUILDCMD=${BUILDCMD:-"dpkg-buildpackage -b -uc -us"}
+BUILDDIR="${BUILDDIR:-"build"}"
+TEST_CMD="${TEST_CMD:-"$CARGO test --all-features --all-targets --release"}"
+
+mkdir -p "${BUILDDIR}"
+echo system >"${BUILDDIR}"/rust-toolchain
+rm -rf "${BUILDDIR}/${CRATE}"
+
+CONTROL="$PWD/${CRATE}/debian/control"
+
+if [ -e "$CONTROL" ]; then
+    # check but only warn, debcargo fails anyway if crates are missing
+    dpkg-checkbuilddeps $PWD/${CRATE}/debian/control || true
+    [ "x$NOCONTROL" = 'x' ] && rm -f "$PWD/${CRATE}/debian/control"
+fi
+
+debcargo package \
+    --config "$PWD/${CRATE}/debian/debcargo.toml" \
+    --changelog-ready \
+    --no-overlay-write-back \
+    --directory "$PWD/"${BUILDDIR}"/${CRATE}" \
+    "${CRATE}" \
+    "$(dpkg-parsechangelog -l "${CRATE}/debian/changelog" -SVersion | sed -e 's/-.*//')"
+
+cd "${BUILDDIR}/${CRATE}"
+rm -f debian/source/format.debcargo.hint
+${BUILDCMD}
+
+# needs all crates build-dependencies, which can be more than what debcargo assembles.
+[ "x$NOTEST" = "x" ] && $TEST_CMD
+
+[ "x$NOCONTROL" = "x" ] && cp debian/control "$CONTROL"
diff --git a/bump.sh b/bump.sh
new file mode 100755
index 0000000..08ad119
--- /dev/null
+++ b/bump.sh
@@ -0,0 +1,44 @@
+#!/bin/bash
+
+package=$1
+
+if [[ -z "$package" ]]; then
+	echo "USAGE:"
+	echo -e "\t bump.sh <crate> [patch|minor|major|<version>]"
+	echo ""
+	echo "Defaults to bumping patch version by 1"
+	exit 0
+fi
+
+cargo_set_version="$(command -v cargo-set-version)"
+if [[ -z "$cargo_set_version" || ! -x "$cargo_set_version" ]]; then
+	echo 'bump.sh requires "cargo set-version", provided by "cargo-edit".'
+	exit 1
+fi
+
+if [[ ! -e "$package/Cargo.toml" ]]; then
+	echo "Invalid crate '$package'"
+	exit 1
+fi
+
+version=$2
+if [[ -z "$version" ]]; then
+	version="patch"
+fi
+
+case "$version" in
+	patch|minor|major)
+		bump="--bump"
+		;;
+	*)
+		bump=
+		;;
+esac
+
+cargo_toml="$package/Cargo.toml"
+changelog="$package/debian/changelog"
+
+cargo set-version -p "$package" $bump "$version"
+version="$(cargo metadata --format-version=1 | jq ".packages[] | select(.name == \"$package\").version" | sed -e 's/\"//g')"
+DEBFULLNAME="Proxmox Support Team" DEBEMAIL="support@proxmox.com" dch --no-conf --changelog "$changelog" --newversion "$version-1" --distribution stable
+git commit --edit -sm "bump $package to $version-1" Cargo.toml "$cargo_toml" "$changelog"
diff --git a/pve-qemu-server-pci/Cargo.toml b/pve-qemu-server-pci/Cargo.toml
new file mode 100644
index 0000000..d3f4401
--- /dev/null
+++ b/pve-qemu-server-pci/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "pve-qemu-server-pci"
+version = "0.1.0"
+authors.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+homepage.workspace = true
+exclude.workspace = true
+rust-version.workspace = true
+
+[dependencies]
+strum.workspace = true
+
+pve-api-types.workspace = true
diff --git a/pve-qemu-server-pci/debian/changelog b/pve-qemu-server-pci/debian/changelog
new file mode 100644
index 0000000..54847b9
--- /dev/null
+++ b/pve-qemu-server-pci/debian/changelog
@@ -0,0 +1,5 @@
+rust-pve-qemu-server-pci (0.1.0) trixie; urgency=medium
+
+  * initial release
+
+ -- Proxmox Support Team <support@proxmox.com>  Tue, 01 Sep 2026 14:02:25 +0200
diff --git a/pve-qemu-server-pci/debian/control b/pve-qemu-server-pci/debian/control
new file mode 100644
index 0000000..9edd0b9
--- /dev/null
+++ b/pve-qemu-server-pci/debian/control
@@ -0,0 +1,36 @@
+Source: rust-pve-qemu-server-pci
+Section: rust
+Priority: optional
+Build-Depends: debhelper-compat (= 13),
+ dh-sequence-cargo
+Build-Depends-Arch: cargo:native <!nocheck>,
+ rustc:native (>= 1.94) <!nocheck>,
+ libstd-rust-dev <!nocheck>,
+ librust-pve-api-types-8+default-dev (>= 8.1-~~) <!nocheck>,
+ librust-strum-0.26+default-dev <!nocheck>,
+ librust-strum-0.26+derive-dev <!nocheck>
+Maintainer: Proxmox Support Team <support@proxmox.com>
+Standards-Version: 4.7.2
+Vcs-Git: git://git.proxmox.com/git/pve-qemu-server-rs.git
+Vcs-Browser: https://git.proxmox.com/?p=pve-qemu-server-rs.git
+Homepage: https://proxmox.com
+X-Cargo-Crate: pve-qemu-server-pci
+
+Package: librust-pve-qemu-server-pci-dev
+Architecture: any
+Multi-Arch: same
+Depends:
+ ${misc:Depends},
+ librust-pve-api-types-8+default-dev (>= 8.1-~~),
+ librust-strum-0.26+default-dev,
+ librust-strum-0.26+derive-dev
+Provides:
+ librust-pve-qemu-server-pci+default-dev (= ${binary:Version}),
+ librust-pve-qemu-server-pci-0-dev (= ${binary:Version}),
+ librust-pve-qemu-server-pci-0+default-dev (= ${binary:Version}),
+ librust-pve-qemu-server-pci-0.1-dev (= ${binary:Version}),
+ librust-pve-qemu-server-pci-0.1+default-dev (= ${binary:Version}),
+ librust-pve-qemu-server-pci-0.1.0-dev (= ${binary:Version}),
+ librust-pve-qemu-server-pci-0.1.0+default-dev (= ${binary:Version})
+Description: Rust crate "pve-qemu-server-pci" - Rust source code
+ Source code for Debianized Rust crate "pve-qemu-server-pci"
diff --git a/pve-qemu-server-pci/debian/copyright b/pve-qemu-server-pci/debian/copyright
new file mode 100644
index 0000000..01138fa
--- /dev/null
+++ b/pve-qemu-server-pci/debian/copyright
@@ -0,0 +1,18 @@
+Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+
+Files:
+ *
+Copyright: 2026 Proxmox Server Solutions GmbH <support@proxmox.com>
+License: AGPL-3.0-or-later
+ This program is free software: you can redistribute it and/or modify it under
+ the terms of the GNU Affero General Public License as published by the Free
+ Software Foundation, either version 3 of the License, or (at your option) any
+ later version.
+ .
+ This program is distributed in the hope that it will be useful, but WITHOUT
+ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+ details.
+ .
+ You should have received a copy of the GNU Affero General Public License along
+ with this program. If not, see <https://www.gnu.org/licenses/>.
diff --git a/pve-qemu-server-pci/debian/debcargo.toml b/pve-qemu-server-pci/debian/debcargo.toml
new file mode 100644
index 0000000..1d52b16
--- /dev/null
+++ b/pve-qemu-server-pci/debian/debcargo.toml
@@ -0,0 +1,7 @@
+overlay = "."
+crate_src_path = ".."
+maintainer = "Proxmox Support Team <support@proxmox.com>"
+
+[source]
+vcs_git = "git://git.proxmox.com/git/pve-qemu-server-rs.git"
+vcs_browser = "https://git.proxmox.com/?p=pve-qemu-server-rs.git"
diff --git a/pve-qemu-server-pci/src/constants.rs b/pve-qemu-server-pci/src/constants.rs
new file mode 100644
index 0000000..ea6db21
--- /dev/null
+++ b/pve-qemu-server-pci/src/constants.rs
@@ -0,0 +1,7 @@
+//! Limits the layouts are built around.
+
+/// Number of slots on a PCI bus. Slot 0x00 is always taken by the bridge
+/// itself, so only [`USABLE_SLOTS_PER_BUS`] of them can hold a device.
+pub(crate) const SLOTS_PER_BUS: usize = 32;
+pub(crate) const USABLE_SLOTS_PER_BUS: u8 = SLOTS_PER_BUS as u8 - 1;
+pub(crate) const FUNCTIONS_PER_DEVICE: usize = 8;
diff --git a/pve-qemu-server-pci/src/error.rs b/pve-qemu-server-pci/src/error.rs
new file mode 100644
index 0000000..5c4f7de
--- /dev/null
+++ b/pve-qemu-server-pci/src/error.rs
@@ -0,0 +1,24 @@
+use std::{error::Error, fmt::Display};
+
+use crate::Device;
+
+/// Errors that can occur while looking up a PCI address.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[non_exhaustive]
+pub enum LayoutError {
+    /// The given configuration id is not a device this crate knows about.
+    InvalidConfigId,
+    /// The device is not part of the layout it was looked up in.
+    NoAddressFound(Device),
+}
+
+impl Display for LayoutError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            LayoutError::InvalidConfigId => write!(f, "invalid configuration id"),
+            LayoutError::NoAddressFound(device) => write!(f, "address '{device}' not found"),
+        }
+    }
+}
+
+impl Error for LayoutError {}
diff --git a/pve-qemu-server-pci/src/hardware_config.rs b/pve-qemu-server-pci/src/hardware_config.rs
new file mode 100644
index 0000000..a348353
--- /dev/null
+++ b/pve-qemu-server-pci/src/hardware_config.rs
@@ -0,0 +1,156 @@
+use std::sync::Arc;
+use std::sync::atomic::AtomicU16;
+
+use pve_api_types::{ClusterResourceHostArch as Arch, QemuConfigOstype, QemuConfigScsihw};
+
+#[derive(Default, Clone, Debug, PartialEq)]
+pub enum Mainboard {
+    #[default]
+    I440fx,
+    Q35,
+    Virt,
+}
+
+impl std::fmt::Display for Mainboard {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let text = match self {
+            Mainboard::I440fx => "i440fx",
+            Mainboard::Q35 => "q35",
+            Mainboard::Virt => "virt",
+        };
+        f.write_str(text)
+    }
+}
+
+impl Mainboard {
+    pub fn has_pcie(&self) -> bool {
+        match self {
+            Mainboard::I440fx => false,
+            Mainboard::Q35 | Mainboard::Virt => true,
+        }
+    }
+}
+
+/// The parts of a guest configuration that influence which PCI layout is used
+/// and which quirks have to be applied to it.
+///
+/// This is deliberately not the full guest config: it only carries what the
+/// address generation needs, so that callers can build it from whatever
+/// configuration representation they have.
+#[derive(Default, Clone, Debug)]
+pub struct HardwareConfig {
+    pub arch: Arch,
+    pub mainboard: Mainboard,
+    pub scsihw: Option<QemuConfigScsihw>,
+    /// Highest `scsihw` controller index in use. Controllers from index 2 on
+    /// live on `pci.4`, so this decides whether that bridge is needed.
+    pub max_scsihw: u8,
+    pub legacy_igd: bool,
+    pub ostype: Option<QemuConfigOstype>,
+    /// QEMU machine version as `(major, minor, pve)`, where the PVE specific
+    /// revision is optional.
+    pub machine_version: (u16, u16, Option<u16>),
+    /// The recorded required `+pveX` version
+    pub required_pve_version: Arc<AtomicU16>,
+}
+
+impl HardwareConfig {
+    /// Builds a [`HardwareConfig`] from the raw configuration values.
+    ///
+    /// Unparsable `arch`, `scsihw` and `ostype` values are not an error here:
+    /// `arch` falls back to x86_64 and the other two to `None`, which is the
+    /// same defaulting the Perl implementation does.
+    #[allow(clippy::too_many_arguments)]
+    pub fn new(
+        arch: &str,
+        machine_type: String,
+        scsihw: Option<String>,
+        max_scsihw: u8,
+        legacy_igd: bool,
+        ostype: Option<String>,
+        major: u16,
+        minor: u16,
+        pve: Option<u16>,
+    ) -> Self {
+        let arch = match arch.parse().unwrap_or(Arch::X8664) {
+            Arch::UnknownEnumValue(_) => Arch::X8664,
+            other => other,
+        };
+        let scsihw = scsihw.and_then(|hw| hw.parse().ok());
+        let ostype = ostype.and_then(|ostype| ostype.parse().ok());
+
+        let mainboard = match arch {
+            Arch::Aarch64 => Mainboard::Virt,
+            Arch::X8664 if machine_type.contains("q35") => Mainboard::Q35,
+            Arch::X8664 | Arch::UnknownEnumValue(_) => Mainboard::I440fx,
+        };
+        Self {
+            arch,
+            mainboard,
+            scsihw,
+            max_scsihw,
+            legacy_igd,
+            ostype,
+            machine_version: (major, minor, pve),
+            required_pve_version: Arc::new(AtomicU16::new(0)),
+        }
+    }
+
+    /// Whether the guest's root bus is PCIe. True for q35 machines and for
+    /// aarch64, which only has a PCIe host bridge.
+    pub fn has_pcie(&self) -> bool {
+        self.mainboard.has_pcie()
+    }
+
+    /// Returns if the mainboard is a Q35 variant.
+    pub fn is_q35(&self) -> bool {
+        self.mainboard == Mainboard::Q35
+    }
+
+    /// Whether each SCSI disk gets its own controller, which needs the extra
+    /// `pci.3` bridge.
+    pub fn virtio_scsi_single(&self) -> bool {
+        self.scsihw == Some(QemuConfigScsihw::VirtioScsiSingle)
+    }
+
+    /// Whether the machine version is at least `major.minor.pve`.
+    ///
+    /// A machine without a PVE revision counts as '+pve0'.
+    pub fn version_at_least(&self, major: u16, minor: u16, pve: Option<u16>) -> bool {
+        match (self.machine_version.0, self.machine_version.1).cmp(&(major, minor)) {
+            std::cmp::Ordering::Less => false,
+            std::cmp::Ordering::Greater => true,
+            std::cmp::Ordering::Equal => {
+                let ours = self.machine_version.2.unwrap_or(0);
+                let pve = pve.unwrap_or(0);
+                let ok = ours >= pve;
+                if ok && pve > 0 {
+                    self.required_pve_version
+                        .fetch_max(pve, std::sync::atomic::Ordering::SeqCst);
+                }
+                ok
+            }
+        }
+    }
+
+    /// Returns the version number for Windows OS types or 0 for other.
+    ///
+    /// Windows XP era OS types are represented by `5`, and Vista era ones by `6`.
+    pub fn windows_version(&self) -> u8 {
+        match self.ostype.unwrap_or(QemuConfigOstype::Other) {
+            QemuConfigOstype::Wxp | QemuConfigOstype::W2k | QemuConfigOstype::W2k3 => 5,
+            QemuConfigOstype::W2k8 | QemuConfigOstype::Wvista => 6,
+            QemuConfigOstype::Win7 => 7,
+            QemuConfigOstype::Win8 => 8,
+            QemuConfigOstype::Win10 => 10,
+            QemuConfigOstype::Win11 => 11,
+            _ => 0,
+        }
+    }
+
+    /// Returns the recorded required `+pveX` version.
+    pub fn required_pve_version(&self) -> u16 {
+        self.required_pve_version
+            .load(std::sync::atomic::Ordering::Relaxed)
+    }
+}
diff --git a/pve-qemu-server-pci/src/layout/legacy.rs b/pve-qemu-server-pci/src/layout/legacy.rs
new file mode 100644
index 0000000..e6693ca
--- /dev/null
+++ b/pve-qemu-server-pci/src/layout/legacy.rs
@@ -0,0 +1,628 @@
+use crate::constants::SLOTS_PER_BUS;
+use crate::hardware_config::Mainboard;
+use crate::layout::bridge_slots;
+use crate::{Bus, Device, Function, HardwareConfig, Layout, LayoutError, PciAddress, Slot};
+
+const PCI_1: [Slot; SLOTS_PER_BUS] = bridge_slots(&[
+    single_device!(Net(6)),
+    single_device!(Net(7)),
+    single_device!(Net(8)),
+    single_device!(Net(9)),
+    single_device!(Net(10)),
+    single_device!(Net(11)),
+    single_device!(Net(12)),
+    single_device!(Net(13)),
+    single_device!(Net(14)),
+    single_device!(Net(15)),
+    single_device!(Net(16)),
+    single_device!(Net(17)),
+    single_device!(Net(18)),
+    single_device!(Net(19)),
+    single_device!(Net(20)),
+    single_device!(Net(21)),
+    single_device!(Net(22)),
+    single_device!(Net(23)),
+    single_device!(Net(24)),
+    single_device!(Net(25)),
+    single_device!(Net(26)),
+    single_device!(Net(27)),
+    single_device!(Net(28)),
+    single_device!(Net(29)),
+    single_device!(Net(30)),
+    single_device!(Net(31)),
+    single_device!(XhciController(0)),
+    Slot::Single(Function::FixedBridge(Bus::Pci(4), &PCI_4)),
+    single_device!(Rng),
+    single_device!(Bridge(Bus::Pci(99))), // same as pci.2 but for igd
+]);
+
+const PCI_2: [Slot; SLOTS_PER_BUS] = bridge_slots(&[
+    single_device!(VirtioBlk(6)),
+    single_device!(VirtioBlk(7)),
+    single_device!(VirtioBlk(8)),
+    single_device!(VirtioBlk(9)),
+    single_device!(VirtioBlk(10)),
+    single_device!(VirtioBlk(11)),
+    single_device!(VirtioBlk(12)),
+    single_device!(VirtioBlk(13)),
+    single_device!(VirtioBlk(14)),
+    single_device!(VirtioBlk(15)),
+    single_device!(Ivshmem),
+    single_device!(Audio),
+    single_device!(Hostpci(4)),
+    single_device!(Hostpci(5)),
+    single_device!(Hostpci(6)),
+    single_device!(Hostpci(7)),
+    single_device!(Hostpci(8)),
+    single_device!(Hostpci(9)),
+    single_device!(Hostpci(10)),
+    single_device!(Hostpci(11)),
+    single_device!(Hostpci(12)),
+    single_device!(Hostpci(13)),
+    single_device!(Hostpci(14)),
+    single_device!(Hostpci(15)),
+]);
+
+const PCI_3: [Slot; SLOTS_PER_BUS] = bridge_slots(&[
+    single_device!(ScsiController(0)),
+    single_device!(ScsiController(1)),
+    single_device!(ScsiController(2)),
+    single_device!(ScsiController(3)),
+    single_device!(ScsiController(4)),
+    single_device!(ScsiController(5)),
+    single_device!(ScsiController(6)),
+    single_device!(ScsiController(7)),
+    single_device!(ScsiController(8)),
+    single_device!(ScsiController(9)),
+    single_device!(ScsiController(10)),
+    single_device!(ScsiController(11)),
+    single_device!(ScsiController(12)),
+    single_device!(ScsiController(13)),
+    single_device!(ScsiController(14)),
+    single_device!(ScsiController(15)),
+    single_device!(ScsiController(16)),
+    single_device!(ScsiController(17)),
+    single_device!(ScsiController(18)),
+    single_device!(ScsiController(19)),
+    single_device!(ScsiController(20)),
+    single_device!(ScsiController(21)),
+    single_device!(ScsiController(22)),
+    single_device!(ScsiController(23)),
+    single_device!(ScsiController(24)),
+    single_device!(ScsiController(25)),
+    single_device!(ScsiController(26)),
+    single_device!(ScsiController(27)),
+    single_device!(ScsiController(28)),
+    single_device!(ScsiController(29)),
+    single_device!(ScsiController(30)),
+]);
+
+const PCI_4: [Slot; SLOTS_PER_BUS] = bridge_slots(&[
+    single_device!(ScsiController(2)),
+    single_device!(ScsiController(3)),
+    single_device!(ScsiController(4)),
+]);
+
+/// Builds the PCI layout of a guest.
+///
+/// With `virtio_scsi_single` the SCSI controllers move off the root bus onto
+/// their own `pci.3` bridge, which is why this cannot be a constant.
+pub(crate) fn get_legacy_pci_layout<'a>(pcie: bool, virtio_scsi_single: bool) -> Layout<'a> {
+    Layout {
+        pcie,
+        root: [
+            Slot::Reserved,
+            single_device!(Piix3Controller), // actually on 0x01.2 but the function number is added by QemuServer/USB.pm
+            single_device!(Vga(0)),
+            single_device!(Balloon),
+            single_device!(Watchdog),
+            if virtio_scsi_single {
+                Slot::Single(Function::FixedBridge(Bus::Pci(3), &PCI_3))
+            } else {
+                single_device!(ScsiController(0))
+            },
+            single_device!(ScsiController(1)),
+            single_device!(Ahci),
+            single_device!(GuestAgent),
+            single_device!(SpiceSerial),
+            single_device!(VirtioBlk(0)),
+            single_device!(VirtioBlk(1)),
+            single_device!(VirtioBlk(2)),
+            single_device!(VirtioBlk(3)),
+            single_device!(VirtioBlk(4)),
+            single_device!(VirtioBlk(5)),
+            single_device!(Hostpci(0)),
+            single_device!(Hostpci(1)),
+            single_device!(Net(0)),
+            single_device!(Net(1)),
+            single_device!(Net(2)),
+            single_device!(Net(3)),
+            single_device!(Net(4)),
+            single_device!(Net(5)),
+            single_device!(Vga(1)),
+            single_device!(Vga(2)),
+            single_device!(Vga(3)),
+            single_device!(Hostpci(2)),
+            single_device!(Hostpci(3)),
+            Slot::Reserved, // usb-host (pve-usb.cfg)
+            Slot::Single(Function::FixedBridge(Bus::Pci(1), &PCI_1)),
+            Slot::Single(Function::FixedBridge(Bus::Pci(2), &PCI_2)),
+        ],
+    }
+}
+
+/// The PCIe layout of a q35 guest.
+///
+/// Only contains the devices on the PCIe bus itself, not the ones nested on
+/// the PCI bridges below it.
+#[expect(deprecated)]
+pub(crate) const LEGACY_PCIE_LAYOUT: Layout = Layout {
+    pcie: true,
+    root: [
+        Slot::Reserved,
+        single_device!(Vga(0)),
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Multi(&[
+            Function::RootPort(Bus::LegacyRootPort(5), Device::Hostpcie(4)),
+            Function::RootPort(Bus::LegacyRootPort(6), Device::Hostpcie(5)),
+            Function::RootPort(Bus::LegacyRootPort(7), Device::Hostpcie(6)),
+            Function::RootPort(Bus::LegacyRootPort(8), Device::Hostpcie(7)),
+            Function::RootPort(Bus::LegacyRootPort(9), Device::Hostpcie(8)),
+            Function::RootPort(Bus::LegacyRootPort(10), Device::Hostpcie(9)),
+            Function::RootPort(Bus::LegacyRootPort(11), Device::Hostpcie(10)),
+            Function::RootPort(Bus::LegacyRootPort(12), Device::Hostpcie(11)),
+        ]),
+        Slot::Multi(&[
+            Function::RootPort(Bus::LegacyRootPort(13), Device::Hostpcie(12)),
+            Function::RootPort(Bus::LegacyRootPort(14), Device::Hostpcie(13)),
+            Function::RootPort(Bus::LegacyRootPort(15), Device::Hostpcie(14)),
+            Function::RootPort(Bus::LegacyRootPort(16), Device::Hostpcie(15)),
+            Function::Unused,
+            Function::Unused,
+            Function::Unused,
+            Function::Unused,
+        ]),
+        Slot::Unused,
+        Slot::Unused,
+        single_device!(Ivshmem),
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Multi(&[
+            Function::Device(Device::Ich9Uhci(4)),
+            Function::Device(Device::Ich9Uhci(5)),
+            Function::Device(Device::Ich9Uhci(6)),
+            Function::Unused,
+            Function::Unused,
+            Function::Unused,
+            Function::Unused,
+            Function::Device(Device::Ich9Ehci(2)),
+        ]),
+        single_device!(Ich9Audio),
+        Slot::Multi(&[
+            Function::RootPort(Bus::LegacyRootPort(1), Device::Hostpcie(0)),
+            Function::RootPort(Bus::LegacyRootPort(2), Device::Hostpcie(1)),
+            Function::RootPort(Bus::LegacyRootPort(3), Device::Hostpcie(2)),
+            Function::RootPort(Bus::LegacyRootPort(4), Device::Hostpcie(3)),
+            Function::Unused,
+            Function::Unused,
+            Function::Unused,
+            Function::Unused,
+        ]),
+        Slot::Multi(&[
+            Function::Device(Device::Ich9Uhci(1)),
+            Function::Device(Device::Ich9Uhci(2)),
+            Function::Device(Device::Ich9Uhci(3)),
+            Function::Unused,
+            Function::Unused,
+            Function::Unused,
+            Function::Unused,
+            Function::Device(Device::Ich9Ehci(1)),
+        ]),
+        Slot::Single(Function::FixedBridge(
+            Bus::Pcidmi,
+            &bridge_slots(&[
+                single_device!(Bridge(Bus::Pci(0))),
+                single_device!(Bridge(Bus::Pci(1))),
+                single_device!(Bridge(Bus::Pci(2))),
+                single_device!(Bridge(Bus::Pci(3))),
+            ]),
+        )),
+        Slot::Reserved, // ICH9 LPC, SATA and SMBus controllers
+    ],
+};
+
+/// The PCIe layout for Windows 7 guests, whose driver cannot cope with
+/// passed through devices behind root ports.
+///
+/// Only contains the `hostpci` slots, everything else is handled by
+/// [`LEGACY_PCIE_LAYOUT`].
+pub(crate) const LEGACY_PCIE_LAYOUT_WIN7: Layout = Layout {
+    pcie: true,
+    root: [
+        Slot::Reserved,
+        Slot::Reserved,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        single_device!(Hostpcie(4)),
+        single_device!(Hostpcie(5)),
+        single_device!(Hostpcie(6)),
+        single_device!(Hostpcie(7)),
+        single_device!(Hostpcie(8)),
+        single_device!(Hostpcie(9)),
+        single_device!(Hostpcie(10)),
+        single_device!(Hostpcie(0)),
+        single_device!(Hostpcie(1)),
+        single_device!(Hostpcie(2)),
+        single_device!(Hostpcie(3)),
+        Slot::Reserved,
+        single_device!(Hostpcie(11)),
+        single_device!(Hostpcie(12)),
+        single_device!(Hostpcie(13)),
+        single_device!(Hostpcie(14)),
+        single_device!(Hostpcie(15)),
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Reserved,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+    ],
+};
+
+/// Whether a (mapped) id asks for the `virtio-scsi-single` variant of the
+/// layout, where every SCSI disk gets its own controller on `pci.3`.
+fn is_virtio_scsi_single_id(id: &str) -> bool {
+    id.starts_with("virtioscsi")
+}
+
+/// Maps (conflicting) legacy PCI IDs to ones we can represent here.
+fn map_legacy_pci_id(original: &str) -> &str {
+    // we can't request  scsihwX and virtioscsiX at the same time,
+    // and that's the only thing living on 'pci.3'
+    if original == "pci.3" {
+        return "scsihw0";
+    }
+
+    //  the legacy-igd device must be the same as the primary adapter
+    //  and requires  vga=none
+    if original == "legacy-igd" {
+        return "vga";
+    }
+
+    //  when using legacy-igd, pci.2 is on a different address due to
+    //  driver constraints
+    if original == "pci.2-igd" {
+        return "pci.99";
+    }
+
+    original
+}
+
+/// Looks up the PCI bridge a device sits on, see
+/// [`crate::pci_bridge_for_device`].
+pub(crate) fn pci_bridge_for_device(id: &str) -> Option<Bus> {
+    let new_id = map_legacy_pci_id(id);
+    // the root bus is never a bridge we have to add ourselves, so it does not
+    // matter whether it is PCI or PCIe here
+    let layout = get_legacy_pci_layout(false, is_virtio_scsi_single_id(new_id));
+    let device = new_id.parse().ok()?;
+    let addr = layout.find_device(&device).ok()?;
+    Some(addr.bus)
+}
+
+/// Returns if the caller wants a PCIE address instead of a PCI one based on the id.
+fn is_pcie_id(id: &str) -> bool {
+    id.starts_with("hostpcie") || id == "vga" || id == "ivshmem"
+}
+
+pub(crate) fn address(hw: &HardwareConfig, id: &str) -> Result<PciAddress, LayoutError> {
+    let (layout, new_id) = match hw.mainboard {
+        Mainboard::Q35 if is_pcie_id(id) => {
+            let layout = if hw.windows_version() == 7 && id.starts_with("hostpcie") {
+                LEGACY_PCIE_LAYOUT_WIN7
+            } else {
+                LEGACY_PCIE_LAYOUT
+            };
+            (layout, id)
+        }
+        Mainboard::Q35 | Mainboard::Virt | Mainboard::I440fx => {
+            let new_id = map_legacy_pci_id(id);
+            let layout = get_legacy_pci_layout(
+                hw.mainboard == Mainboard::Virt,
+                is_virtio_scsi_single_id(id),
+            );
+            (layout, new_id)
+        }
+    };
+
+    layout.find_device(&new_id.parse()?)
+}
+
+#[cfg(test)]
+pub(crate) mod test {
+    use std::collections::HashSet;
+
+    use super::{LEGACY_PCIE_LAYOUT, get_legacy_pci_layout, pci_bridge_for_device};
+    use crate::hardware_config::Mainboard;
+    use crate::layout::legacy::is_pcie_id;
+    use crate::{Bus, Device, HardwareConfig, Layout, PciAddress};
+
+    pub const LEGACY_ADDRS: [(&str, u8, u8); 121] = [
+        ("ahci0", 0, 7),
+        ("audio0", 2, 12),
+        ("balloon0", 0, 3),
+        ("ehci", 0, 1),
+        ("hostpci0", 0, 16),
+        ("hostpci1", 0, 17),
+        ("hostpci2", 0, 27),
+        ("hostpci3", 0, 28),
+        ("hostpci4", 2, 13),
+        ("hostpci5", 2, 14),
+        ("hostpci6", 2, 15),
+        ("hostpci7", 2, 16),
+        ("hostpci8", 2, 17),
+        ("hostpci9", 2, 18),
+        ("hostpci10", 2, 19),
+        ("hostpci11", 2, 20),
+        ("hostpci12", 2, 21),
+        ("hostpci13", 2, 22),
+        ("hostpci14", 2, 23),
+        ("hostpci15", 2, 24),
+        ("ivshmem", 2, 11),
+        ("legacy-igd", 0, 2),
+        ("net0", 0, 18),
+        ("net1", 0, 19),
+        ("net2", 0, 20),
+        ("net3", 0, 21),
+        ("net4", 0, 22),
+        ("net5", 0, 23),
+        ("net6", 1, 1),
+        ("net7", 1, 2),
+        ("net8", 1, 3),
+        ("net9", 1, 4),
+        ("net10", 1, 5),
+        ("net11", 1, 6),
+        ("net12", 1, 7),
+        ("net13", 1, 8),
+        ("net14", 1, 9),
+        ("net15", 1, 10),
+        ("net16", 1, 11),
+        ("net17", 1, 12),
+        ("net18", 1, 13),
+        ("net19", 1, 14),
+        ("net20", 1, 15),
+        ("net21", 1, 16),
+        ("net22", 1, 17),
+        ("net23", 1, 18),
+        ("net24", 1, 19),
+        ("net25", 1, 20),
+        ("net26", 1, 21),
+        ("net27", 1, 22),
+        ("net28", 1, 23),
+        ("net29", 1, 24),
+        ("net30", 1, 25),
+        ("net31", 1, 26),
+        ("pci.1", 0, 30),
+        ("pci.2", 0, 31),
+        ("pci.2-igd", 1, 30),
+        ("pci.3", 0, 5),
+        ("pci.4", 1, 28),
+        ("piix3", 0, 1),
+        ("qga0", 0, 8),
+        ("rng0", 1, 29),
+        ("scsihw0", 0, 5),
+        ("scsihw1", 0, 6),
+        ("scsihw2", 4, 1),
+        ("scsihw3", 4, 2),
+        ("scsihw4", 4, 3),
+        ("spice", 0, 9),
+        ("vga", 0, 2),
+        ("vga1", 0, 24),
+        ("vga2", 0, 25),
+        ("vga3", 0, 26),
+        ("virtio0", 0, 10),
+        ("virtio1", 0, 11),
+        ("virtio2", 0, 12),
+        ("virtio3", 0, 13),
+        ("virtio4", 0, 14),
+        ("virtio5", 0, 15),
+        ("virtio6", 2, 1),
+        ("virtio7", 2, 2),
+        ("virtio8", 2, 3),
+        ("virtio9", 2, 4),
+        ("virtio10", 2, 5),
+        ("virtio11", 2, 6),
+        ("virtio12", 2, 7),
+        ("virtio13", 2, 8),
+        ("virtio14", 2, 9),
+        ("virtio15", 2, 10),
+        ("virtioscsi0", 3, 1),
+        ("virtioscsi1", 3, 2),
+        ("virtioscsi2", 3, 3),
+        ("virtioscsi3", 3, 4),
+        ("virtioscsi4", 3, 5),
+        ("virtioscsi5", 3, 6),
+        ("virtioscsi6", 3, 7),
+        ("virtioscsi7", 3, 8),
+        ("virtioscsi8", 3, 9),
+        ("virtioscsi9", 3, 10),
+        ("virtioscsi10", 3, 11),
+        ("virtioscsi11", 3, 12),
+        ("virtioscsi12", 3, 13),
+        ("virtioscsi13", 3, 14),
+        ("virtioscsi14", 3, 15),
+        ("virtioscsi15", 3, 16),
+        ("virtioscsi16", 3, 17),
+        ("virtioscsi17", 3, 18),
+        ("virtioscsi18", 3, 19),
+        ("virtioscsi19", 3, 20),
+        ("virtioscsi20", 3, 21),
+        ("virtioscsi21", 3, 22),
+        ("virtioscsi22", 3, 23),
+        ("virtioscsi23", 3, 24),
+        ("virtioscsi24", 3, 25),
+        ("virtioscsi25", 3, 26),
+        ("virtioscsi26", 3, 27),
+        ("virtioscsi27", 3, 28),
+        ("virtioscsi28", 3, 29),
+        ("virtioscsi29", 3, 30),
+        ("virtioscsi30", 3, 31),
+        ("watchdog", 0, 4),
+        ("xhci", 1, 27),
+    ];
+
+    fn pci_addr(pcie: bool, bus: u8, device: u8) -> PciAddress {
+        PciAddress {
+            bus: if pcie { Bus::Pcie(bus) } else { Bus::Pci(bus) },
+            device,
+            function: 0,
+        }
+    }
+
+    #[test]
+    /// Test string id to addr, big overlap with config2command tests in qemu-server
+    fn test_pci_addr_maps() {
+        // copied and sorted from src/PVE/QemuServer/PCI.pm
+
+        let i440fx = HardwareConfig::default();
+        let q35 = HardwareConfig {
+            mainboard: Mainboard::Q35,
+            ..Default::default()
+        };
+        let arm = HardwareConfig {
+            arch: pve_api_types::ClusterResourceHostArch::Aarch64,
+            mainboard: Mainboard::Virt,
+            ..Default::default()
+        };
+        for (input, bus, device) in LEGACY_ADDRS {
+            let addr =
+                crate::address(&i440fx, input).unwrap_or_else(|_| panic!("{input} not found"));
+            let expected = pci_addr(false, bus, device);
+            assert_eq!(addr, expected, "tested {input} on i440fx");
+
+            if !is_pcie_id(input) {
+                let addr =
+                    crate::address(&q35, input).unwrap_or_else(|_| panic!("{input} not found"));
+                let expected = pci_addr(false, bus, device);
+                assert_eq!(addr, expected, "tested {input} on q35");
+            }
+
+            // arm is on pcie
+            let addr = crate::address(&arm, input).unwrap_or_else(|_| panic!("{input} not found"));
+            let expected = pci_addr(bus == 0, bus, device);
+            assert_eq!(addr, expected, "tested {input} on virt");
+        }
+    }
+
+    #[test]
+    /// Test string id to addr, big overlap with config2command tests in qemu-server
+    fn test_pcie_addr_maps() {
+        // copied and sorted from src/PVE/QemuServer/PCI.pm
+        let tests = [
+            ("hostpcie0", true, 1),
+            ("hostpcie1", true, 2),
+            ("hostpcie2", true, 3),
+            ("hostpcie3", true, 4),
+            ("hostpcie4", true, 5),
+            ("hostpcie5", true, 6),
+            ("hostpcie6", true, 7),
+            ("hostpcie7", true, 8),
+            ("hostpcie8", true, 9),
+            ("hostpcie9", true, 10),
+            ("hostpcie10", true, 11),
+            ("hostpcie11", true, 12),
+            ("hostpcie12", true, 13),
+            ("hostpcie13", true, 14),
+            ("hostpcie14", true, 15),
+            ("hostpcie15", true, 16),
+            ("ivshmem", false, 20),
+            ("vga", false, 1),
+        ];
+
+        let pcie_machine = HardwareConfig {
+            mainboard: Mainboard::Q35,
+            ..Default::default()
+        };
+        for (input, rp, num) in tests {
+            let addr = crate::address(&pcie_machine, input)
+                .unwrap_or_else(|_| panic!("device not found: {input}"));
+            let (bus, device) = if rp {
+                #[expect(deprecated)]
+                (Bus::LegacyRootPort(num), 0)
+            } else {
+                (Bus::Pcie(0), num)
+            };
+            let expected = PciAddress::new(bus, device, 0);
+            assert_eq!(addr, expected, "tested {input}");
+        }
+    }
+
+    /// A bus can only be used once the bridge or root port creating it has
+    /// been yielded, otherwise a command line built in iteration order would
+    /// reference a bus QEMU does not know yet.
+    fn assert_buses_before_devices(layout: &Layout) {
+        let mut buses = HashSet::from([if layout.pcie {
+            Bus::Pcie(0)
+        } else {
+            Bus::Pci(0)
+        }]);
+
+        for used in layout.iter() {
+            assert!(
+                buses.contains(&used.addr.bus),
+                "{:?} is on {:?}, which was not created yet",
+                used.device,
+                used.addr.bus
+            );
+            match used.device {
+                Device::Bridge(bus) => buses.insert(bus),
+                Device::RootPort(bus) => buses.insert(bus),
+                _ => continue,
+            };
+        }
+    }
+
+    #[test]
+    fn test_bus_order() {
+        assert_buses_before_devices(&LEGACY_PCIE_LAYOUT);
+        assert_buses_before_devices(&get_legacy_pci_layout(false, false));
+        assert_buses_before_devices(&get_legacy_pci_layout(false, true));
+        assert_buses_before_devices(&get_legacy_pci_layout(true, true));
+    }
+
+    #[test]
+    /// The bus of a device has to agree with the address it is given. Note
+    /// that the root bus is reported as bus zero, not as no bus at all.
+    fn test_pci_bridge_for_device() {
+        for (input, bus, _) in LEGACY_ADDRS {
+            assert_eq!(
+                pci_bridge_for_device(input),
+                Some(Bus::Pci(bus)),
+                "for {input}"
+            );
+        }
+
+        assert_eq!(pci_bridge_for_device("no-such-device"), None);
+    }
+}
diff --git a/pve-qemu-server-pci/src/layout/mod.rs b/pve-qemu-server-pci/src/layout/mod.rs
new file mode 100644
index 0000000..5439c17
--- /dev/null
+++ b/pve-qemu-server-pci/src/layout/mod.rs
@@ -0,0 +1,48 @@
+use crate::Slot;
+use crate::constants::SLOTS_PER_BUS;
+
+// local helper macro to make the lines shorter
+macro_rules! single_device {
+    ($i:ident ( $e:expr )) => {
+        $crate::Slot::Single($crate::Function::Device($crate::Device::$i($e)))
+    };
+    ($i:ident) => {
+        $crate::Slot::Single($crate::Function::Device($crate::Device::$i))
+    };
+}
+
+pub(crate) mod legacy;
+
+/// Constructs a correctly sized list of bridge slots, starting at address 0x01
+/// because 0x00 is always taken by the bridge itself.
+///
+/// Panics if more slots than fit are passed in. As this is a `const fn` used
+/// from constants only, that turns into a compile time error.
+pub(crate) const fn bridge_slots<'a>(slots: &'a [Slot<'a>]) -> [Slot<'a>; SLOTS_PER_BUS] {
+    const PLACEHOLDER: Slot = Slot::Unused;
+    let mut out = [PLACEHOLDER; SLOTS_PER_BUS];
+    if slots.len() > SLOTS_PER_BUS - 1 {
+        panic!("too many devices on bridge");
+    }
+    out[0] = Slot::Reserved;
+    let mut i = 1;
+    while i < SLOTS_PER_BUS && i - 1 < slots.len() {
+        out[i] = slots[i - 1];
+        i += 1;
+    }
+    out
+}
+
+#[cfg(test)]
+mod test {
+    use crate::Slot;
+    use crate::constants::SLOTS_PER_BUS;
+    use crate::layout::bridge_slots;
+
+    #[test]
+    fn test_bridge_slots() {
+        const BRIDGE: [Slot; SLOTS_PER_BUS] =
+            bridge_slots(&[Slot::Reserved, single_device!(Viommu)]);
+        assert_eq!(BRIDGE[1], Slot::Reserved);
+    }
+}
diff --git a/pve-qemu-server-pci/src/lib.rs b/pve-qemu-server-pci/src/lib.rs
new file mode 100644
index 0000000..43a3886
--- /dev/null
+++ b/pve-qemu-server-pci/src/lib.rs
@@ -0,0 +1,33 @@
+//! Generation of the PCI and PCIe addresses of the devices of a PVE guest.
+//!
+//! Which address a device gets is part of the guest's ABI: moving a device
+//! makes the guest operating system see it as a new one, so the assignment has
+//! to stay stable for the lifetime of a VM. This crate therefore describes the
+//! assignment as a static [`layout`] and only looks addresses up in it.
+//!
+//! The entry points below reproduce what the Perl implementation in
+//! `PVE::QemuServer::PCI` does, so that both can be used interchangeably.
+
+pub mod constants;
+
+mod hardware_config;
+pub use hardware_config::HardwareConfig;
+
+mod types;
+pub use types::*;
+
+mod error;
+pub use error::LayoutError;
+
+pub mod layout;
+
+/// Returns the number of the PCI bridge `id` sits on, or `None` if it is on
+/// the root bus or not a known device.
+pub fn pci_bridge_for_device(id: &str) -> Option<Bus> {
+    layout::legacy::pci_bridge_for_device(id)
+}
+
+/// Returns the address of the device with the configuration id `id`.
+pub fn address(hw: &HardwareConfig, id: &str) -> Result<PciAddress, LayoutError> {
+    layout::legacy::address(hw, id)
+}
diff --git a/pve-qemu-server-pci/src/types/bus.rs b/pve-qemu-server-pci/src/types/bus.rs
new file mode 100644
index 0000000..052ef00
--- /dev/null
+++ b/pve-qemu-server-pci/src/types/bus.rs
@@ -0,0 +1,198 @@
+#[cfg(test)]
+use strum::VariantArray;
+
+use crate::{Device, LayoutError};
+
+/// The kinds of bridge PVE adds itself, as opposed to the ones a QEMU machine
+/// type already brings along.
+///
+/// Each kind gets its own set of bridges so that the slot of a device only
+/// depends on its own index, not on how many devices of other kinds exist.
+#[cfg_attr(test, derive(VariantArray))]
+#[derive(Hash, Clone, Copy, Debug, PartialEq, Eq)]
+pub enum PveBridge {
+    Net,
+    Scsi,
+    VirtioBlk,
+    VirtioFs,
+    Hostpci,
+}
+
+impl PveBridge {
+    /// The bus formed by the `index`th bridge of this kind.
+    pub const fn new_bus(self, index: u8) -> Bus {
+        Bus::Pve { kind: self, index }
+    }
+
+    /// The device this kind of bridge carries at the given device index.
+    pub const fn device(self, index: u8) -> Device {
+        match self {
+            PveBridge::Net => Device::Net(index),
+            PveBridge::Scsi => Device::ScsiController(index),
+            PveBridge::VirtioBlk => Device::VirtioBlk(index),
+            PveBridge::VirtioFs => Device::VirtioFs(index),
+            PveBridge::Hostpci => Device::Hostpci(index),
+        }
+    }
+}
+
+impl std::fmt::Display for PveBridge {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let text = match self {
+            PveBridge::Net => "net",
+            PveBridge::Scsi => "scsi",
+            PveBridge::VirtioBlk => "virtio-blk",
+            PveBridge::VirtioFs => "virtio-fs",
+            PveBridge::Hostpci => "hostpci",
+        };
+        f.write_str(text)
+    }
+}
+
+impl std::str::FromStr for PveBridge {
+    type Err = LayoutError;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let kind = match s {
+            "net" => PveBridge::Net,
+            "scsi" => PveBridge::Scsi,
+            "virtio-blk" => PveBridge::VirtioBlk,
+            "virtio-fs" => PveBridge::VirtioFs,
+            "hostpci" => PveBridge::Hostpci,
+            _ => return Err(LayoutError::InvalidConfigId),
+        };
+        Ok(kind)
+    }
+}
+
+/// A bus a device can be attached to, identified the same way QEMU's `bus=`
+/// parameter does.
+#[derive(Hash, Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Bus {
+    /// A PCI bus. Index 0 is the root bus of a non-PCIe machine.
+    Pci(u8),
+    /// A PCIe bus. Index 0 is the root bus of a PCIe machine.
+    Pcie(u8),
+    /// The bus behind a PCIe root port, which can hold a single device.
+    RootPort(u8),
+    /// A legacy, deprecated variant of a root port.
+    #[deprecated]
+    LegacyRootPort(u8),
+    /// The PCI bus behind the q35 DMI-to-PCI bridge.
+    Pcidmi,
+    /// A bridge for the guest's built-in devices.
+    Sys(u8),
+    /// One of the bridges PVE adds per device kind, see [`PveBridge`].
+    Pve { kind: PveBridge, index: u8 },
+}
+
+type BusConstructor = fn(u8) -> Bus;
+
+impl Bus {
+    /// The QEMU device model needed to create this bus below `parent`, or
+    /// `None` if the bus is not created by a bridge device.
+    pub fn bridge_model(self, parent: Bus) -> Option<&'static str> {
+        match self {
+            #[expect(deprecated)]
+            Bus::Pcie(_) | Bus::RootPort(_) | Bus::LegacyRootPort(_) => None,
+            Bus::Pcidmi => Some("i82801b11-bridge"),
+            Bus::Sys(_) | Bus::Pci(_) | Bus::Pve { .. } => Some(match parent {
+                #[expect(deprecated)]
+                Bus::Pcie(_) | Bus::RootPort(_) | Bus::LegacyRootPort(_) => "pcie-pci-bridge",
+                _ => "pci-bridge",
+            }),
+        }
+    }
+}
+
+impl std::fmt::Display for Bus {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Bus::Pci(i) => write!(f, "pci.{i}"),
+            Bus::Pcie(i) => write!(f, "pcie.{i}"),
+            Bus::RootPort(i) => write!(f, "pve.root-port{i}"),
+            #[expect(deprecated)]
+            Bus::LegacyRootPort(i) => write!(f, "ich9-pcie-port-{i}"),
+            Bus::Sys(i) => write!(f, "pve.sys{i}"),
+            Bus::Pcidmi => write!(f, "pcidmi"),
+            Bus::Pve { kind, index } => {
+                write!(f, "pve.{kind}{index}")
+            }
+        }
+    }
+}
+
+impl std::str::FromStr for Bus {
+    type Err = LayoutError;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        // NOTE: the prefixes here must be checked before the generic 'pve.'
+        // one below, which would otherwise swallow them.
+        const PREFIXES: &[(&str, BusConstructor)] = &[
+            ("pci.", Bus::Pci),
+            ("pcie.", Bus::Pcie),
+            ("pve.root-port", Bus::RootPort),
+            #[expect(deprecated)]
+            ("ich9-pcie-port-", Bus::LegacyRootPort),
+            ("pve.sys", Bus::Sys),
+        ];
+
+        if s == "pcidmi" {
+            return Ok(Bus::Pcidmi);
+        }
+
+        for (prefix, c) in PREFIXES {
+            if let Some(rest) = s.strip_prefix(prefix) {
+                let index: u8 = rest.parse().map_err(|_| LayoutError::InvalidConfigId)?;
+                return Ok(c(index));
+            }
+        }
+
+        if let Some(rest) = s.strip_prefix("pve.")
+            && let Some(idx) = rest.find(|c: char| c.is_ascii_digit())
+        {
+            let kind: PveBridge = rest[0..idx]
+                .parse()
+                .map_err(|_| LayoutError::InvalidConfigId)?;
+            let index: u8 = rest[idx..]
+                .parse()
+                .map_err(|_| LayoutError::InvalidConfigId)?;
+            return Ok(Bus::Pve { kind, index });
+        }
+
+        Err(LayoutError::InvalidConfigId)
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use strum::VariantArray;
+
+    use crate::Bus;
+
+    use super::PveBridge;
+
+    #[test]
+    fn test_roundtrip() {
+        let test = |bus: Bus| {
+            let id = bus.to_string();
+            let new_bus: Bus = id
+                .parse()
+                .unwrap_or_else(|_| panic!("could not parse {id}"));
+            assert_eq!(bus, new_bus);
+        };
+
+        test(Bus::Pcidmi);
+        test(Bus::Pci(99));
+        test(Bus::Pcie(99));
+
+        for kind in PveBridge::VARIANTS.iter().copied() {
+            test(Bus::Pve { kind, index: 99 });
+        }
+
+        test(Bus::RootPort(99));
+        #[expect(deprecated)]
+        test(Bus::LegacyRootPort(99));
+        test(Bus::Sys(99));
+    }
+}
diff --git a/pve-qemu-server-pci/src/types/device.rs b/pve-qemu-server-pci/src/types/device.rs
new file mode 100644
index 0000000..d3bf60b
--- /dev/null
+++ b/pve-qemu-server-pci/src/types/device.rs
@@ -0,0 +1,173 @@
+use std::{fmt::Display, str::FromStr};
+use strum::{EnumDiscriminants, VariantArray};
+
+use crate::{Bus, LayoutError};
+
+/// A device that needs a PCI address.
+///
+/// Variants carrying an index map to a configuration key of the same name plus
+/// that index, for example `Net(3)` is the guest's `net3`.
+#[derive(Hash, Debug, Clone, Copy, PartialEq, Eq, EnumDiscriminants)]
+#[strum_discriminants(derive(VariantArray))]
+pub enum Device {
+    Ahci,
+    Balloon,
+    Watchdog,
+    GuestAgent,
+    SpiceSerial,
+    Rng,
+    Audio,
+    Ivshmem,
+    Bridge(Bus),
+    RootPort(Bus),
+    Vga(u8),
+    Viommu,
+    VirtioFs(u8),
+    Net(u8),
+    ScsiController(u8),
+    VirtioBlk(u8),
+    Piix3Controller,
+    XhciController(u8),
+    Hostpci(u8),
+    Hostpcie(u8),
+    /// The EHCI USB controllers of the ICH9 chipset, numbered from one like
+    /// their QEMU models.
+    Ich9Ehci(u8),
+    /// The UHCI USB controllers of the ICH9 chipset, numbered from one like
+    /// their QEMU models.
+    Ich9Uhci(u8),
+    /// The HDA controller legacy q35 guests always have, independent of a
+    /// configured `audio0`.
+    Ich9Audio,
+}
+
+impl Display for Device {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let text = match self {
+            Device::Ahci => "ahci",
+            Device::Balloon => "balloon",
+            Device::Watchdog => "watchdog",
+            Device::GuestAgent => "guest-agent",
+            Device::SpiceSerial => "spice-serial",
+            Device::Rng => "rng",
+            Device::Audio => "audio",
+            Device::Ivshmem => "ivshmem",
+            Device::Viommu => "viommu",
+            Device::Piix3Controller => "Piix3Controller",
+            Device::Ich9Audio => "ich9-audio",
+            _ => "",
+        };
+        if !text.is_empty() {
+            return f.write_str(text);
+        }
+        let text = match self {
+            Device::Bridge(bus) => bus.to_string(),
+            Device::RootPort(bus) => bus.to_string(),
+            Device::Vga(i) => format!("vga{i}"),
+            Device::VirtioFs(i) => format!("virtiofs{i}"),
+            Device::Net(i) => format!("net{i}"),
+            Device::ScsiController(i) => format!("scsihw{i}"),
+            Device::VirtioBlk(i) => format!("virtioblk{i}"),
+            Device::XhciController(i) => format!("xhci{i}"),
+            Device::Hostpci(i) => format!("hostpci{i}"),
+            Device::Hostpcie(i) => format!("hostpcie{i}"),
+            Device::Ich9Ehci(i) => format!("ich9-ehci{i}"),
+            Device::Ich9Uhci(i) => format!("ich9-uhci{i}"),
+            _ => String::new(),
+        };
+        write!(f, "{text}")
+    }
+}
+
+/// Rebuilds the single possible [`Device`] of a discriminant, which only works
+/// for the variants that carry no index.
+impl TryFrom<DeviceDiscriminants> for Device {
+    type Error = LayoutError;
+
+    fn try_from(value: DeviceDiscriminants) -> Result<Self, Self::Error> {
+        let res = match value {
+            DeviceDiscriminants::Ahci => Device::Ahci,
+            DeviceDiscriminants::Balloon => Device::Balloon,
+            DeviceDiscriminants::Watchdog => Device::Watchdog,
+            DeviceDiscriminants::GuestAgent => Device::GuestAgent,
+            DeviceDiscriminants::SpiceSerial => Device::SpiceSerial,
+            DeviceDiscriminants::Rng => Device::Rng,
+            DeviceDiscriminants::Audio => Device::Audio,
+            DeviceDiscriminants::Ivshmem => Device::Ivshmem,
+            DeviceDiscriminants::Piix3Controller => Device::Piix3Controller,
+            DeviceDiscriminants::Ich9Audio => Device::Ich9Audio,
+            DeviceDiscriminants::Bridge
+            | DeviceDiscriminants::RootPort
+            | DeviceDiscriminants::Vga
+            | DeviceDiscriminants::Viommu
+            | DeviceDiscriminants::VirtioFs
+            | DeviceDiscriminants::Net
+            | DeviceDiscriminants::ScsiController
+            | DeviceDiscriminants::VirtioBlk
+            | DeviceDiscriminants::XhciController
+            | DeviceDiscriminants::Hostpci
+            | DeviceDiscriminants::Hostpcie
+            | DeviceDiscriminants::Ich9Ehci
+            | DeviceDiscriminants::Ich9Uhci => return Err(LayoutError::InvalidConfigId),
+        };
+        Ok(res)
+    }
+}
+
+type DeviceConstructor = fn(u8) -> Device;
+
+/// Parses the configuration id of a device, for example `net0` or `hostpci3`.
+impl FromStr for Device {
+    type Err = LayoutError;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        // NOTE: order matters, the longer prefixes have to come first so that
+        // 'virtio' does not shadow 'virtioscsi' and 'hostpci' not 'hostpcie'.
+        const PREFIXES: &[(&str, DeviceConstructor)] = &[
+            ("net", Device::Net),
+            ("scsihw", Device::ScsiController),
+            ("virtioscsi", Device::ScsiController),
+            ("virtiofs", Device::VirtioFs),
+            ("virtio", Device::VirtioBlk),
+            ("hostpcie", Device::Hostpcie),
+            ("hostpci", Device::Hostpci),
+        ];
+
+        for (prefix, c) in PREFIXES {
+            if let Some(index) = s.strip_prefix(prefix) {
+                let index = index.parse().map_err(|_| LayoutError::InvalidConfigId)?;
+                return Ok(c(index));
+            }
+        }
+
+        let class = match s {
+            "viommu" => Device::Viommu,
+            "vga" => Device::Vga(0),
+            "vga1" => Device::Vga(1),
+            "vga2" => Device::Vga(2),
+            "vga3" => Device::Vga(3),
+            "xhci" => Device::XhciController(0),
+            "ahci0" => Device::Ahci,
+            "balloon0" => Device::Balloon,
+            "watchdog" => Device::Watchdog,
+            "qga0" => Device::GuestAgent,
+            "spice" => Device::SpiceSerial,
+            "rng0" => Device::Rng,
+            "audio0" => Device::Audio,
+            "ivshmem" => Device::Ivshmem,
+            // legacy rules
+            "ehci" | "piix3" => Device::Piix3Controller,
+            "legacy-igd" => Device::Vga(0),
+            "pci.2-igd" => Device::Bridge(Bus::Pci(99)),
+            other => {
+                if let Ok(bridge) = other.parse() {
+                    return Ok(Device::Bridge(bridge));
+                }
+
+                return Err(LayoutError::InvalidConfigId);
+            }
+        };
+
+        Ok(class)
+    }
+}
diff --git a/pve-qemu-server-pci/src/types/layout.rs b/pve-qemu-server-pci/src/types/layout.rs
new file mode 100644
index 0000000..889b961
--- /dev/null
+++ b/pve-qemu-server-pci/src/types/layout.rs
@@ -0,0 +1,329 @@
+use std::collections::VecDeque;
+
+use crate::constants::{FUNCTIONS_PER_DEVICE, SLOTS_PER_BUS, USABLE_SLOTS_PER_BUS};
+use crate::{Bus, Device, LayoutError, PciAddress, PveBridge};
+
+/// What sits at one function number of a slot.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Function<'a> {
+    /// Occupied by something outside of this crate's control, for example by
+    /// the machine type's own configuration file.
+    Reserved,
+    /// Free for future use. Kept explicit so that the surrounding entries keep
+    /// their address when something is added later.
+    Unused,
+    Device(Device),
+    /// A bridge with a statically known set of slots behind it.
+    FixedBridge(Bus, &'a [Slot<'a>; SLOTS_PER_BUS]),
+    /// A PCIe root port with the single device attached to it.
+    RootPort(Bus, Device),
+}
+
+/// What sits at one slot of a bus.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Slot<'a> {
+    /// Occupied by something outside of this crate's control.
+    Reserved,
+    /// Free for future use, see [`Function::Unused`].
+    Unused,
+    /// A single device on function 0.
+    Single(Function<'a>),
+    /// A multifunction slot with one entry per function number.
+    Multi(&'a [Function<'a>; FUNCTIONS_PER_DEVICE]),
+    /// As many bridges of `kind` as are needed to hold `max` devices. The
+    /// bridges share this slot by using one function number each.
+    DynamicBridges { kind: PveBridge, max: u8 },
+}
+
+/// The complete slot assignment of a guest, starting at the root bus.
+///
+/// A layout is a static description: it lists every address the crate can
+/// hand out, regardless of which devices a concrete guest configures.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct Layout<'a> {
+    /// Whether the root bus is PCIe. Only the root bus is affected, nested
+    /// buses stay plain PCI.
+    pub pcie: bool,
+    pub root: [Slot<'a>; SLOTS_PER_BUS],
+}
+
+impl Layout<'_> {
+    /// Looks up the address of a device in this layout.
+    ///
+    /// The first match wins, so the order of the layout is significant: a
+    /// device may legitimately appear in two places, for example a SCSI
+    /// controller that moves to its own bridge with `virtio-scsi-single`.
+    pub fn find_device(&self, device: &Device) -> Result<PciAddress, LayoutError> {
+        self.iter()
+            .find_map(|used| (used.device == *device).then_some(used.addr))
+            .ok_or(LayoutError::NoAddressFound(*device))
+    }
+
+    /// Iterates over every address this layout hands out.
+    ///
+    /// Bridges are yielded before the devices behind them, so the result can
+    /// be turned into a command line in order.
+    pub fn iter(&self) -> AddressAssignments<'_> {
+        AddressAssignments::new(self)
+    }
+}
+
+/// The address a layout assigns to a device.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub struct AddressAssignment {
+    pub addr: PciAddress,
+    pub device: Device,
+    /// Whether QEMU has to be told that other functions of this slot are in
+    /// use. Only ever set on function 0.
+    pub multifunction: bool,
+}
+
+/// Iterator over the address assignments of a [`Layout`], see
+/// [`Layout::iter`].
+#[derive(Debug)]
+pub struct AddressAssignments<'a> {
+    resolved: VecDeque<AddressAssignment>,
+    bridge_slot_stack: Vec<(Slot<'a>, PciAddress)>,
+}
+
+impl<'a> AddressAssignments<'a> {
+    pub(crate) fn new(layout: &Layout<'a>) -> Self {
+        let bus_type = if layout.pcie {
+            Bus::Pcie(0)
+        } else {
+            Bus::Pci(0)
+        };
+        let mut stack: Vec<(Slot<'a>, PciAddress)> = Vec::new();
+        for (device, slot) in layout.root.iter().enumerate().rev() {
+            let address = PciAddress::new(bus_type, device as u8, 0);
+            stack.push((*slot, address));
+        }
+        Self {
+            bridge_slot_stack: stack,
+            resolved: VecDeque::new(),
+        }
+    }
+
+    /// Resolves one function of a slot into the queue of address assignments.
+    ///
+    /// A bridge is queued before the devices behind it: fixed bridges push
+    /// their slots onto the stack, which is only drained once the queue is
+    /// empty again.
+    fn handle_function(&mut self, addr: PciAddress, function: Function<'a>, multifunction: bool) {
+        match function {
+            Function::Reserved | Function::Unused => {}
+            Function::Device(device) => {
+                self.resolved.push_back(AddressAssignment {
+                    addr,
+                    device,
+                    multifunction,
+                });
+            }
+            Function::RootPort(bus, device) => {
+                self.resolved.push_back(AddressAssignment {
+                    addr,
+                    device: Device::RootPort(bus),
+                    multifunction,
+                });
+                self.resolved.push_back(AddressAssignment {
+                    addr: PciAddress::new(bus, 0, 0),
+                    device,
+                    multifunction: false,
+                });
+            }
+            Function::FixedBridge(bus_type, slots) => {
+                self.resolved.push_back(AddressAssignment {
+                    addr,
+                    device: Device::Bridge(bus_type),
+                    multifunction,
+                });
+                for (device, slot) in slots.iter().enumerate().rev() {
+                    let addr = PciAddress::new(bus_type, device as u8, 0);
+                    self.bridge_slot_stack.push((*slot, addr));
+                }
+            }
+        }
+    }
+
+    /// Resolves a slot of dynamic bridges into the queue of address
+    /// assignments.
+    ///
+    /// Unlike fixed bridges, the devices behind them are queued right away,
+    /// as they are generated from the bridge kind rather than listed in the
+    /// layout.
+    fn handle_dynamic_bridges(&mut self, mut addr: PciAddress, kind: PveBridge, max: u8) {
+        // the bridges share one slot, so there cannot be more of them than the
+        // slot has function numbers
+        let num_bridges = max.div_ceil(USABLE_SLOTS_PER_BUS);
+        debug_assert!(usize::from(num_bridges) <= FUNCTIONS_PER_DEVICE);
+
+        for bridge_index in 0..num_bridges {
+            addr.function = bridge_index;
+            let bus = kind.new_bus(bridge_index);
+            self.resolved.push_back(AddressAssignment {
+                addr,
+                device: Device::Bridge(bus),
+                multifunction: bridge_index == 0 && num_bridges > 1,
+            });
+            for slot_nr in 1..=USABLE_SLOTS_PER_BUS {
+                // u16 so that a bigger 'max' cannot overflow
+                let index = u16::from(bridge_index) * u16::from(USABLE_SLOTS_PER_BUS)
+                    + u16::from(slot_nr)
+                    - 1;
+                if index >= u16::from(max) {
+                    break;
+                }
+                self.resolved.push_back(AddressAssignment {
+                    addr: PciAddress::new(bus, slot_nr, 0),
+                    device: kind.device(index as u8),
+                    multifunction: false,
+                });
+            }
+        }
+    }
+}
+
+impl Iterator for AddressAssignments<'_> {
+    type Item = AddressAssignment;
+
+    fn next(&mut self) -> Option<Self::Item> {
+        loop {
+            if let Some(assignment) = self.resolved.pop_front() {
+                return Some(assignment);
+            }
+            if let Some((slot, mut addr)) = self.bridge_slot_stack.pop() {
+                match slot {
+                    Slot::Reserved | Slot::Unused => {}
+                    Slot::Single(function) => self.handle_function(addr, function, false),
+                    Slot::Multi(functions) => {
+                        for (function_nr, function) in functions.iter().enumerate() {
+                            addr.function = function_nr as u8;
+                            self.handle_function(addr, *function, function_nr == 0);
+                        }
+                    }
+                    Slot::DynamicBridges { kind, max } => {
+                        self.handle_dynamic_bridges(addr, kind, max)
+                    }
+                }
+            }
+
+            if self.resolved.is_empty() && self.bridge_slot_stack.is_empty() {
+                return None;
+            }
+        }
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use crate::constants::SLOTS_PER_BUS;
+    use crate::layout::bridge_slots;
+    use crate::{AddressAssignment, Bus, Device, PciAddress};
+
+    use super::{Function, Layout, Slot};
+
+    const BRIDGE: [Slot; SLOTS_PER_BUS] =
+        bridge_slots(&[Slot::Single(Function::Device(Device::Watchdog))]);
+    const LAYOUT: Layout<'static> = Layout {
+        pcie: false,
+        root: crate::layout::bridge_slots(&[
+            Slot::Single(Function::Device(Device::Rng)),
+            Slot::Single(Function::Device(Device::Audio)),
+            Slot::Reserved,
+            Slot::Unused,
+            Slot::Multi(&[
+                Function::Reserved,
+                Function::Unused,
+                Function::Unused,
+                Function::Unused,
+                Function::Device(Device::Viommu),
+                Function::Unused,
+                Function::Unused,
+                Function::Unused,
+            ]),
+            Slot::Single(Function::FixedBridge(Bus::Pci(99), &BRIDGE)),
+        ]),
+    };
+
+    #[test]
+    fn basic_layout() {
+        let test = |device: Device, addr: PciAddress| {
+            let found = LAYOUT
+                .find_device(&device)
+                .unwrap_or_else(|_| panic!("could not find {device:?}"));
+            assert_eq!(found, addr);
+        };
+
+        test(Device::Rng, PciAddress::new(Bus::Pci(0), 1, 0));
+        test(Device::Audio, PciAddress::new(Bus::Pci(0), 2, 0));
+        test(Device::Viommu, PciAddress::new(Bus::Pci(0), 5, 4));
+        test(Device::Watchdog, PciAddress::new(Bus::Pci(99), 1, 0));
+    }
+
+    #[test]
+    fn iterator() {
+        let mut iter = LAYOUT.iter();
+        let pci0 = Bus::Pci(0);
+        assert_eq!(
+            iter.next(),
+            Some(AddressAssignment {
+                addr: PciAddress {
+                    bus: pci0,
+                    device: 1,
+                    function: 0
+                },
+                device: Device::Rng,
+                multifunction: false,
+            })
+        );
+        assert_eq!(
+            iter.next(),
+            Some(AddressAssignment {
+                addr: PciAddress {
+                    bus: pci0,
+                    device: 2,
+                    function: 0
+                },
+                device: Device::Audio,
+                multifunction: false,
+            })
+        );
+        assert_eq!(
+            iter.next(),
+            Some(AddressAssignment {
+                addr: PciAddress {
+                    bus: pci0,
+                    device: 5,
+                    function: 4
+                },
+                device: Device::Viommu,
+                multifunction: false,
+            })
+        );
+        assert_eq!(
+            iter.next(),
+            Some(AddressAssignment {
+                addr: PciAddress {
+                    bus: pci0,
+                    device: 6,
+                    function: 0
+                },
+                device: Device::Bridge(Bus::Pci(99)),
+                multifunction: false,
+            })
+        );
+        assert_eq!(
+            iter.next(),
+            Some(AddressAssignment {
+                addr: PciAddress {
+                    bus: Bus::Pci(99),
+                    device: 1,
+                    function: 0
+                },
+                device: Device::Watchdog,
+                multifunction: false,
+            })
+        );
+        assert_eq!(iter.next(), None);
+    }
+}
diff --git a/pve-qemu-server-pci/src/types/mod.rs b/pve-qemu-server-pci/src/types/mod.rs
new file mode 100644
index 0000000..3847745
--- /dev/null
+++ b/pve-qemu-server-pci/src/types/mod.rs
@@ -0,0 +1,11 @@
+mod bus;
+pub use bus::{Bus, PveBridge};
+
+mod device;
+pub use device::Device;
+
+mod pci_address;
+pub use pci_address::PciAddress;
+
+mod layout;
+pub use layout::{AddressAssignment, AddressAssignments, Function, Layout, Slot};
diff --git a/pve-qemu-server-pci/src/types/pci_address.rs b/pve-qemu-server-pci/src/types/pci_address.rs
new file mode 100644
index 0000000..dcef298
--- /dev/null
+++ b/pve-qemu-server-pci/src/types/pci_address.rs
@@ -0,0 +1,36 @@
+use crate::Bus;
+
+/// The complete address of a device: the bus it sits on plus the slot and
+/// function number within that bus.
+#[derive(Hash, Debug, Clone, Copy, PartialEq, Eq)]
+pub struct PciAddress {
+    pub bus: Bus,
+    pub device: u8,
+    pub function: u8,
+}
+
+impl PciAddress {
+    pub const fn new(bus: Bus, device: u8, function: u8) -> Self {
+        Self {
+            bus,
+            device,
+            function,
+        }
+    }
+
+    /// Renders the address as the `,bus=...,addr=...` suffix of a QEMU
+    /// `-device` argument, including the leading comma.
+    ///
+    /// The function number is only emitted when it is not zero, matching what
+    /// the Perl implementation produces.
+    pub fn to_qemu_addr(&self) -> String {
+        let bus = self.bus;
+        let function = if self.function > 0 {
+            format!(".{}", self.function)
+        } else {
+            String::new()
+        };
+
+        format!(",bus={bus},addr=0x{:x}{function}", self.device)
+    }
+}
diff --git a/rustfmt.toml b/rustfmt.toml
new file mode 100644
index 0000000..f3e454b
--- /dev/null
+++ b/rustfmt.toml
@@ -0,0 +1,2 @@
+edition = "2024"
+style_edition = "2024"
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 12+ messages in thread

* [PATCH pve-qemu-server-rs v2 2/9] pci: legacy: generate pci bridges
  2026-10-15  8:10 [RFC proxmox-perl-rs/qemu-server/qemu-server-rs v2 0/9] pci-handling rewrite (part 1/2/3) Dominik Csapak
  2026-10-15  8:10 ` [PATCH pve-qemu-server-rs v2 1/9] add pve-qemu-server-pci crate for guest PCI address generation Dominik Csapak
@ 2026-10-15  8:10 ` Dominik Csapak
  2026-10-15  8:10 ` [PATCH pve-qemu-server-rs v2 3/9] pci: legacy: generate q35 chipset devices and hostpcie root ports Dominik Csapak
                   ` (6 subsequent siblings)
  8 siblings, 0 replies; 12+ messages in thread
From: Dominik Csapak @ 2026-10-15  8:10 UTC (permalink / raw)
  To: pve-devel

Besides the address of a single device, we also need the list of
bridges a guest has to be started with. Return them from
`pci_bus_devices`, which is intended to return all devices that form a
bus, since those must come before the devices using them on the
commandline.

The list itself follows the perl implementation:
* q35 machines already have the bridges up to 3 in their machine
  config
* pci.3 is only needed for virtio-scsi-single
* pci.4 is only needed for scsi controllers with an index > 1, or
  starting with machine version 11.1

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 pve-qemu-server-pci/src/layout/legacy.rs | 109 ++++++++++++++++++++++-
 pve-qemu-server-pci/src/lib.rs           |   9 ++
 2 files changed, 117 insertions(+), 1 deletion(-)

diff --git a/pve-qemu-server-pci/src/layout/legacy.rs b/pve-qemu-server-pci/src/layout/legacy.rs
index e6693ca..6d5cc81 100644
--- a/pve-qemu-server-pci/src/layout/legacy.rs
+++ b/pve-qemu-server-pci/src/layout/legacy.rs
@@ -331,6 +331,43 @@ pub(crate) fn pci_bridge_for_device(id: &str) -> Option<Bus> {
     Some(addr.bus)
 }
 
+/// Renders the bridge devices a guest needs, see
+/// [`crate::pci_bus_devices`].
+pub(crate) fn pci_bus_devices(hw: &HardwareConfig) -> Result<Vec<String>, LayoutError> {
+    let mut res = Vec::new();
+
+    // some SCSI controllers can only have 7 disks each, so scsi14 and upwards
+    // need scsihw2 and above, which live on pci.4
+    let include_pci4 = hw.version_at_least(11, 1, None) || hw.max_scsihw > 1;
+
+    for i in 1..=4 {
+        // q35.cfg already includes the bridges up to three
+        if i < 4 && hw.mainboard == Mainboard::Q35 {
+            continue;
+        }
+
+        if i == 3 && !hw.virtio_scsi_single() {
+            continue;
+        }
+
+        if i == 4 && !include_pci4 {
+            continue;
+        }
+
+        let id = if i == 2 && hw.legacy_igd {
+            format!("pci.{i}-igd")
+        } else {
+            format!("pci.{i}")
+        };
+
+        let addr = address(hw, &id)?.to_qemu_addr();
+        res.push("-device".to_string());
+        res.push(format!("pci-bridge,id=pci.{i},chassis_nr={i}{addr}"));
+    }
+
+    Ok(res)
+}
+
 /// Returns if the caller wants a PCIE address instead of a PCI one based on the id.
 fn is_pcie_id(id: &str) -> bool {
     id.starts_with("hostpcie") || id == "vga" || id == "ivshmem"
@@ -363,7 +400,9 @@ pub(crate) fn address(hw: &HardwareConfig, id: &str) -> Result<PciAddress, Layou
 pub(crate) mod test {
     use std::collections::HashSet;
 
-    use super::{LEGACY_PCIE_LAYOUT, get_legacy_pci_layout, pci_bridge_for_device};
+    use super::{
+        LEGACY_PCIE_LAYOUT, get_legacy_pci_layout, pci_bridge_for_device, pci_bus_devices,
+    };
     use crate::hardware_config::Mainboard;
     use crate::layout::legacy::is_pcie_id;
     use crate::{Bus, Device, HardwareConfig, Layout, PciAddress};
@@ -625,4 +664,72 @@ pub(crate) mod test {
 
         assert_eq!(pci_bridge_for_device("no-such-device"), None);
     }
+
+    fn hw_config(
+        q35: bool,
+        scsihw: Option<&str>,
+        max_scsihw: u8,
+        legacy_igd: bool,
+    ) -> HardwareConfig {
+        HardwareConfig::new(
+            "x86_64",
+            format!("pc-{}-9.2-pve0", if q35 { "q35" } else { "i440fx" }),
+            scsihw.map(str::to_string),
+            max_scsihw,
+            legacy_igd,
+            None,
+            9,
+            2,
+            None,
+        )
+    }
+
+    fn bridge(nr: u8, addr: &str) -> [String; 2] {
+        [
+            "-device".to_string(),
+            format!("pci-bridge,id=pci.{nr},chassis_nr={nr}{addr}"),
+        ]
+    }
+
+    #[test]
+    fn test_pci_bus_devices() {
+        let pci1 = bridge(1, ",bus=pci.0,addr=0x1e");
+        let pci2 = bridge(2, ",bus=pci.0,addr=0x1f");
+        let pci3 = bridge(3, ",bus=pci.0,addr=0x5");
+        let pci4 = bridge(4, ",bus=pci.1,addr=0x1c");
+
+        let test = |hw: HardwareConfig, expected: &[&[String; 2]]| {
+            let expected: Vec<String> = expected.iter().flat_map(|b| b.iter().cloned()).collect();
+            assert_eq!(pci_bus_devices(&hw), Ok(expected));
+        };
+
+        test(hw_config(false, None, 0, false), &[&pci1, &pci2]);
+
+        // q35 already brings the bridges up to three along
+        test(hw_config(true, None, 0, false), &[]);
+
+        // every SCSI disk gets its own controller on pci.3
+        test(
+            hw_config(false, Some("virtio-scsi-single"), 0, false),
+            &[&pci1, &pci2, &pci3],
+        );
+
+        // scsihw2 and above live on pci.4
+        test(hw_config(false, Some("lsi"), 1, false), &[&pci1, &pci2]);
+        test(
+            hw_config(false, Some("lsi"), 2, false),
+            &[&pci1, &pci2, &pci4],
+        );
+        test(hw_config(true, Some("lsi"), 2, false), &[&pci4]);
+
+        // pci.4 is always added from machine version 11.1 on
+        let mut new_machine = hw_config(false, None, 0, false);
+        new_machine.machine_version = (11, 1, None);
+        test(new_machine, &[&pci1, &pci2, &pci4]);
+
+        // with legacy IGD passthrough pci.2 moves behind pci.1
+        let mut expected_igd = pci2.clone();
+        expected_igd[1] = "pci-bridge,id=pci.2,chassis_nr=2,bus=pci.1,addr=0x1e".to_string();
+        test(hw_config(false, None, 0, true), &[&pci1, &expected_igd]);
+    }
 }
diff --git a/pve-qemu-server-pci/src/lib.rs b/pve-qemu-server-pci/src/lib.rs
index 43a3886..fc982ce 100644
--- a/pve-qemu-server-pci/src/lib.rs
+++ b/pve-qemu-server-pci/src/lib.rs
@@ -27,6 +27,15 @@ pub fn pci_bridge_for_device(id: &str) -> Option<Bus> {
     layout::legacy::pci_bridge_for_device(id)
 }
 
+/// Returns the QEMU arguments for all devices that form the buses `hw`
+/// needs, as alternating `-device` flags and their values.
+///
+/// They are ordered so that every bus is created before the devices on it,
+/// and have to be passed before any other device that sits on these buses.
+pub fn pci_bus_devices(hw: &HardwareConfig) -> Result<Vec<String>, LayoutError> {
+    layout::legacy::pci_bus_devices(hw)
+}
+
 /// Returns the address of the device with the configuration id `id`.
 pub fn address(hw: &HardwareConfig, id: &str) -> Result<PciAddress, LayoutError> {
     layout::legacy::address(hw, id)
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 12+ messages in thread

* [PATCH pve-qemu-server-rs v2 3/9] pci: legacy: generate q35 chipset devices and hostpcie root ports
  2026-10-15  8:10 [RFC proxmox-perl-rs/qemu-server/qemu-server-rs v2 0/9] pci-handling rewrite (part 1/2/3) Dominik Csapak
  2026-10-15  8:10 ` [PATCH pve-qemu-server-rs v2 1/9] add pve-qemu-server-pci crate for guest PCI address generation Dominik Csapak
  2026-10-15  8:10 ` [PATCH pve-qemu-server-rs v2 2/9] pci: legacy: generate pci bridges Dominik Csapak
@ 2026-10-15  8:10 ` Dominik Csapak
  2026-10-15  8:10 ` [PATCH pve-qemu-server-rs v2 4/9] pci: layout: add grouped PCI and PCIe layouts Dominik Csapak
                   ` (5 subsequent siblings)
  8 siblings, 0 replies; 12+ messages in thread
From: Dominik Csapak @ 2026-10-15  8:10 UTC (permalink / raw)
  To: pve-devel

Until now, the q35 chipset devices were added via the pve-q35-4.0.cfg
machine config file, and the root ports for hostpcie4 and above were
added by the caller for each configured device.

The latter can leave a root port without a function 0 in its slot,
e.g. when only hostpci5 is configured. Since guests only scan the
other functions of a slot when function 0 exists (Linux stops in
`pci_scan_slot()`), such a device was not visible in the guest.

To fix that and to have the layout as the single source for the pci
hierarchy, generate both in `pci_bus_devices`:
* the chipset devices with the same ids, properties and order as in
  the config file, which a test checks against a copy of it. Machines
  older than 4.0 used pve-q35.cfg instead, but those are not supported
  anymore.
* the root ports of the configured hostpcie devices, plus the one on
  function 0 of each used slot. Devices that were already visible keep
  their address, and Windows 7 guests still don't get any root ports.

Since the devices of running guests must not change for live
migration, we only generate the chipset devices starting with machine
version 11.1+pve1. For older machines, `uses_q35_config_file` tells
the caller to keep using the config file.

This replaces `print_pcie_root_port`, as we now decide here which root
ports are needed.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 pve-qemu-server-pci/src/hardware_config.rs    |  28 ++
 pve-qemu-server-pci/src/layout/legacy.rs      | 325 +++++++++++++++++-
 pve-qemu-server-pci/src/lib.rs                |   2 +-
 pve-qemu-server-pci/src/types/layout.rs       |  63 +++-
 pve-qemu-server-pci/src/types/mod.rs          |   2 +-
 pve-qemu-server-pci/test-data/pve-q35-4.0.cfg | 163 +++++++++
 6 files changed, 572 insertions(+), 11 deletions(-)
 create mode 100644 pve-qemu-server-pci/test-data/pve-q35-4.0.cfg

diff --git a/pve-qemu-server-pci/src/hardware_config.rs b/pve-qemu-server-pci/src/hardware_config.rs
index a348353..677fe0a 100644
--- a/pve-qemu-server-pci/src/hardware_config.rs
+++ b/pve-qemu-server-pci/src/hardware_config.rs
@@ -1,8 +1,11 @@
+use std::collections::BTreeMap;
 use std::sync::Arc;
 use std::sync::atomic::AtomicU16;
 
 use pve_api_types::{ClusterResourceHostArch as Arch, QemuConfigOstype, QemuConfigScsihw};
 
+use crate::Device;
+
 #[derive(Default, Clone, Debug, PartialEq)]
 pub enum Mainboard {
     #[default]
@@ -31,6 +34,10 @@ impl Mainboard {
     }
 }
 
+/// A passed through PCIe device of the guest.
+#[derive(Default, Clone, Debug, PartialEq, Eq)]
+pub struct HostpcieDevice {}
+
 /// The parts of a guest configuration that influence which PCI layout is used
 /// and which quirks have to be applied to it.
 ///
@@ -52,6 +59,9 @@ pub struct HardwareConfig {
     pub machine_version: (u16, u16, Option<u16>),
     /// The recorded required `+pveX` version
     pub required_pve_version: Arc<AtomicU16>,
+    /// The configured `hostpcie` devices, keyed by their index, which decide
+    /// what root ports are needed.
+    pub hostpcie_devices: BTreeMap<u8, HostpcieDevice>,
 }
 
 impl HardwareConfig {
@@ -93,6 +103,7 @@ impl HardwareConfig {
             ostype,
             machine_version: (major, minor, pve),
             required_pve_version: Arc::new(AtomicU16::new(0)),
+            hostpcie_devices: BTreeMap::new(),
         }
     }
 
@@ -107,6 +118,14 @@ impl HardwareConfig {
         self.mainboard == Mainboard::Q35
     }
 
+    /// Whether the legacy q35 chipset devices come from qemu-server's
+    /// `pve-q35-4.0.cfg` machine config file. Only machines from 11.1+pve1 on
+    /// get them from [`crate::pci_bus_devices`], so older guests stay unchanged
+    /// for live migration.
+    pub fn uses_q35_config_file(&self) -> bool {
+        self.is_q35() && !self.version_at_least(11, 1, Some(1))
+    }
+
     /// Whether each SCSI disk gets its own controller, which needs the extra
     /// `pci.3` bridge.
     pub fn virtio_scsi_single(&self) -> bool {
@@ -133,6 +152,15 @@ impl HardwareConfig {
         }
     }
 
+    /// The configured `hostpcie` devices.
+    pub(crate) fn hostpcie(&self) -> Vec<Device> {
+        self.hostpcie_devices
+            .keys()
+            .copied()
+            .map(Device::Hostpcie)
+            .collect()
+    }
+
     /// Returns the version number for Windows OS types or 0 for other.
     ///
     /// Windows XP era OS types are represented by `5`, and Vista era ones by `6`.
diff --git a/pve-qemu-server-pci/src/layout/legacy.rs b/pve-qemu-server-pci/src/layout/legacy.rs
index 6d5cc81..d89c0bf 100644
--- a/pve-qemu-server-pci/src/layout/legacy.rs
+++ b/pve-qemu-server-pci/src/layout/legacy.rs
@@ -1,7 +1,9 @@
 use crate::constants::SLOTS_PER_BUS;
 use crate::hardware_config::Mainboard;
 use crate::layout::bridge_slots;
-use crate::{Bus, Device, Function, HardwareConfig, Layout, LayoutError, PciAddress, Slot};
+use crate::{
+    Bus, Device, Function, HardwareConfig, Layout, LayoutError, PciAddress, RootPort, Slot,
+};
 
 const PCI_1: [Slot; SLOTS_PER_BUS] = bridge_slots(&[
     single_device!(Net(6)),
@@ -319,6 +321,69 @@ fn map_legacy_pci_id(original: &str) -> &str {
     original
 }
 
+/// The q35 chipset devices that are not part of the QEMU machine type, in the
+/// order they were defined in pve-q35-4.0.cfg, which added them previously.
+/// UHCI controllers use the bus of their EHCI controller, so it comes first.
+#[expect(deprecated)]
+const Q35_CHIPSET_DEVICES: [Device; 18] = [
+    Device::Ich9Ehci(1),
+    Device::Ich9Uhci(1),
+    Device::Ich9Uhci(2),
+    Device::Ich9Uhci(3),
+    Device::Ich9Ehci(2),
+    Device::Ich9Uhci(4),
+    Device::Ich9Uhci(5),
+    Device::Ich9Uhci(6),
+    Device::Ich9Audio,
+    Device::RootPort(Bus::LegacyRootPort(1)),
+    Device::RootPort(Bus::LegacyRootPort(2)),
+    Device::RootPort(Bus::LegacyRootPort(3)),
+    Device::RootPort(Bus::LegacyRootPort(4)),
+    Device::Bridge(Bus::Pcidmi),
+    Device::Bridge(Bus::Pci(0)),
+    Device::Bridge(Bus::Pci(1)),
+    Device::Bridge(Bus::Pci(2)),
+    Device::Bridge(Bus::Pci(3)),
+];
+
+/// Renders a device of [`Q35_CHIPSET_DEVICES`] like the config file did.
+fn print_q35_chipset_device(device: Device) -> Result<String, LayoutError> {
+    let addr = LEGACY_PCIE_LAYOUT.find_device(&device)?;
+    let qemu_addr = addr.to_qemu_addr();
+    let ehci_id = |index: u8| match index {
+        1 => "ehci".to_string(),
+        _ => format!("ehci-{index}"),
+    };
+
+    let res = match device {
+        Device::Ich9Ehci(index) => {
+            let id = ehci_id(index);
+            format!("ich9-usb-ehci{index},id={id},multifunction=on{qemu_addr}")
+        }
+        Device::Ich9Uhci(index) => {
+            // each EHCI controller has three UHCI companions with two ports each
+            let masterbus = ehci_id((index - 1) / 3 + 1);
+            let firstport = (index - 1) % 3 * 2;
+            format!(
+                "ich9-usb-uhci{index},id=uhci-{index},multifunction=on{qemu_addr},masterbus={masterbus}.0,firstport={firstport}"
+            )
+        }
+        Device::Ich9Audio => format!("ich9-intel-hda,id=audio0{qemu_addr}"),
+        #[expect(deprecated)]
+        Device::RootPort(id @ Bus::LegacyRootPort(index)) => {
+            RootPort { addr, id, index }.to_qemu_device()
+        }
+        Device::Bridge(Bus::Pcidmi) => format!("i82801b11-bridge,id=pcidmi{qemu_addr}"),
+        Device::Bridge(Bus::Pci(index)) => {
+            let chassis_nr = index + 1;
+            format!("pci-bridge,id=pci.{index},chassis_nr={chassis_nr}{qemu_addr}")
+        }
+        _ => return Err(LayoutError::NoAddressFound(device)),
+    };
+
+    Ok(res)
+}
+
 /// Looks up the PCI bridge a device sits on, see
 /// [`crate::pci_bridge_for_device`].
 pub(crate) fn pci_bridge_for_device(id: &str) -> Option<Bus> {
@@ -331,17 +396,24 @@ pub(crate) fn pci_bridge_for_device(id: &str) -> Option<Bus> {
     Some(addr.bus)
 }
 
-/// Renders the bridge devices a guest needs, see
+/// Renders the chipset devices, bridges and root ports a guest needs, see
 /// [`crate::pci_bus_devices`].
 pub(crate) fn pci_bus_devices(hw: &HardwareConfig) -> Result<Vec<String>, LayoutError> {
     let mut res = Vec::new();
 
+    if hw.mainboard == Mainboard::Q35 && !hw.uses_q35_config_file() {
+        for device in Q35_CHIPSET_DEVICES {
+            res.push("-device".to_string());
+            res.push(print_q35_chipset_device(device)?);
+        }
+    }
+
     // some SCSI controllers can only have 7 disks each, so scsi14 and upwards
     // need scsihw2 and above, which live on pci.4
     let include_pci4 = hw.version_at_least(11, 1, None) || hw.max_scsihw > 1;
 
     for i in 1..=4 {
-        // q35.cfg already includes the bridges up to three
+        // the bridges up to three are part of the q35 chipset
         if i < 4 && hw.mainboard == Mainboard::Q35 {
             continue;
         }
@@ -365,6 +437,18 @@ pub(crate) fn pci_bus_devices(hw: &HardwareConfig) -> Result<Vec<String>, Layout
         res.push(format!("pci-bridge,id=pci.{i},chassis_nr={i}{addr}"));
     }
 
+    // Windows 7 gets its PCIe devices directly on the root bus
+    if hw.mainboard == Mainboard::Q35 && hw.windows_version() != 7 {
+        for port in LEGACY_PCIE_LAYOUT.root_ports(&hw.hostpcie()) {
+            // the first four root ports are part of the q35 chipset
+            if port.index <= 4 {
+                continue;
+            }
+            res.push("-device".to_string());
+            res.push(port.to_qemu_device());
+        }
+    }
+
     Ok(res)
 }
 
@@ -398,14 +482,15 @@ pub(crate) fn address(hw: &HardwareConfig, id: &str) -> Result<PciAddress, Layou
 
 #[cfg(test)]
 pub(crate) mod test {
-    use std::collections::HashSet;
+    use std::collections::{BTreeMap, HashSet};
 
     use super::{
-        LEGACY_PCIE_LAYOUT, get_legacy_pci_layout, pci_bridge_for_device, pci_bus_devices,
+        LEGACY_PCIE_LAYOUT, Q35_CHIPSET_DEVICES, get_legacy_pci_layout, pci_bridge_for_device,
+        pci_bus_devices,
     };
     use crate::hardware_config::Mainboard;
     use crate::layout::legacy::is_pcie_id;
-    use crate::{Bus, Device, HardwareConfig, Layout, PciAddress};
+    use crate::{Bus, Device, HardwareConfig, HostpcieDevice, Layout, PciAddress};
 
     pub const LEGACY_ADDRS: [(&str, u8, u8); 121] = [
         ("ahci0", 0, 7),
@@ -617,6 +702,48 @@ pub(crate) mod test {
         }
     }
 
+    #[test]
+    /// test pcie port addresses
+    fn pcie_test_rp_addr() {
+        let expected = [
+            (0x1c, 0),
+            (0x1c, 1),
+            (0x1c, 2),
+            (0x1c, 3),
+            (0x10, 0),
+            (0x10, 1),
+            (0x10, 2),
+            (0x10, 3),
+            (0x10, 4),
+            (0x10, 5),
+            (0x10, 6),
+            (0x10, 7),
+            (0x11, 0),
+            (0x11, 1),
+            (0x11, 2),
+            (0x11, 3),
+        ];
+        let devices: Vec<Device> = (0..expected.len() as u8).map(Device::Hostpcie).collect();
+        let expected: Vec<String> = expected
+            .iter()
+            .enumerate()
+            .map(|(i, (addr, fnr))| {
+                let index = i + 1;
+                format!(
+                    "pcie-root-port,id=ich9-pcie-port-{index},addr={addr:02x}.{fnr},x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port={index},chassis={index}",
+                )
+            })
+            .collect();
+        let mut found: Vec<String> = LEGACY_PCIE_LAYOUT
+            .root_ports(&devices)
+            .iter()
+            .map(|port| port.to_qemu_device())
+            .collect();
+        // the ports are returned in layout order, where slot 0x1c comes last
+        found.rotate_right(4);
+        assert_eq!(found, expected);
+    }
+
     /// A bus can only be used once the bridge or root port creating it has
     /// been yielded, otherwise a command line built in iteration order would
     /// reference a bus QEMU does not know yet.
@@ -665,6 +792,35 @@ pub(crate) mod test {
         assert_eq!(pci_bridge_for_device("no-such-device"), None);
     }
 
+    /// The number of q35 chipset devices `pci_bus_devices` generates for `hw`.
+    fn generated_chipset(hw: &HardwareConfig) -> usize {
+        if hw.is_q35() && !hw.uses_q35_config_file() {
+            Q35_CHIPSET_DEVICES.len()
+        } else {
+            0
+        }
+    }
+
+    #[test]
+    fn test_q35_chipset_version() {
+        let test = |q35: bool, version: (u16, u16, Option<u16>), config_file: bool| {
+            let mut hw = hw_config(q35, None, 0, false);
+            hw.machine_version = version;
+            assert_eq!(hw.uses_q35_config_file(), config_file, "for {version:?}");
+            let res = pci_bus_devices(&hw).expect("valid machine");
+            let has_chipset = res
+                .iter()
+                .any(|device| device.starts_with("ich9-usb-ehci1,"));
+            assert_eq!(has_chipset, q35 && !config_file, "for {version:?}");
+        };
+
+        test(true, (11, 1, None), true);
+        test(true, (11, 1, Some(0)), true);
+        test(true, (11, 1, Some(1)), false);
+        test(true, (12, 0, Some(0)), false);
+        test(false, (11, 1, Some(1)), false);
+    }
+
     fn hw_config(
         q35: bool,
         scsihw: Option<&str>,
@@ -700,12 +856,20 @@ pub(crate) mod test {
 
         let test = |hw: HardwareConfig, expected: &[&[String; 2]]| {
             let expected: Vec<String> = expected.iter().flat_map(|b| b.iter().cloned()).collect();
-            assert_eq!(pci_bus_devices(&hw), Ok(expected));
+            let res = pci_bus_devices(&hw).expect("valid machine");
+            // the q35 chipset is tested separately
+            let res: Vec<String> = res
+                .chunks(2)
+                .skip(generated_chipset(&hw))
+                .flatten()
+                .cloned()
+                .collect();
+            assert_eq!(res, expected);
         };
 
         test(hw_config(false, None, 0, false), &[&pci1, &pci2]);
 
-        // q35 already brings the bridges up to three along
+        // the bridges up to three are part of the q35 chipset
         test(hw_config(true, None, 0, false), &[]);
 
         // every SCSI disk gets its own controller on pci.3
@@ -732,4 +896,149 @@ pub(crate) mod test {
         expected_igd[1] = "pci-bridge,id=pci.2,chassis_nr=2,bus=pci.1,addr=0x1e".to_string();
         test(hw_config(false, None, 0, true), &[&pci1, &expected_igd]);
     }
+
+    fn root_port(index: u8, addr: &str) -> [String; 2] {
+        [
+            "-device".to_string(),
+            format!(
+                "pcie-root-port,id=ich9-pcie-port-{index},addr={addr},x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port={index},chassis={index}"
+            ),
+        ]
+    }
+
+    #[test]
+    fn test_pci_bus_devices_root_ports() {
+        let test = |q35: bool, ostype: Option<&str>, hostpcie: &[u8], expected: &[[String; 2]]| {
+            let mut hw = hw_config(q35, None, 0, false);
+            hw.ostype = ostype.and_then(|ostype| ostype.parse().ok());
+            hw.hostpcie_devices = hostpcie
+                .iter()
+                .map(|&index| (index, HostpcieDevice::default()))
+                .collect();
+            let expected: Vec<String> = expected.iter().flatten().cloned().collect();
+            let res = pci_bus_devices(&hw).expect("valid machine");
+            // only look at the root ports, the rest is tested separately
+            let res: Vec<String> = res
+                .chunks(2)
+                .skip(generated_chipset(&hw))
+                .filter(|device| device[1].starts_with("pcie-root-port"))
+                .flatten()
+                .cloned()
+                .collect();
+            assert_eq!(res, expected, "for {hostpcie:?}");
+        };
+
+        // the first four root ports are part of the q35 chipset
+        test(true, None, &[0, 3], &[]);
+        test(true, None, &[4], &[root_port(5, "10.0")]);
+        test(
+            true,
+            None,
+            &[0, 12, 4],
+            &[root_port(5, "10.0"), root_port(13, "11.0")],
+        );
+        // function 0 of a used slot has to exist for guests to see the others
+        test(
+            true,
+            None,
+            &[5, 7],
+            &[
+                root_port(5, "10.0"),
+                root_port(6, "10.1"),
+                root_port(8, "10.3"),
+            ],
+        );
+        test(
+            true,
+            None,
+            &[14],
+            &[root_port(13, "11.0"), root_port(15, "11.2")],
+        );
+
+        // windows 7 has its devices directly on the root bus
+        test(true, Some("win7"), &[5], &[]);
+        test(false, None, &[5], &[]);
+    }
+
+    /// Parses QEMU device options into the id, the driver and the remaining
+    /// properties, with the address normalized to slot and function.
+    fn normalize_device(
+        id: &str,
+        driver: &str,
+        props: &mut dyn Iterator<Item = (String, String)>,
+    ) -> (String, String, BTreeMap<String, String>) {
+        let mut props: BTreeMap<String, String> = props.collect();
+        if let Some(addr) = props.get_mut("addr") {
+            let addr_str = addr.trim_start_matches("0x");
+            let (slot, function) = addr_str.split_once('.').unwrap_or((addr_str, "0"));
+            let slot = u8::from_str_radix(slot, 16).expect("valid slot");
+            *addr = format!("{slot:02x}.{function}");
+        }
+        (id.to_string(), driver.to_string(), props)
+    }
+
+    /// Parses the device sections of a QEMU config file.
+    fn parse_qemu_cfg(cfg: &str) -> Vec<(String, String, BTreeMap<String, String>)> {
+        let mut devices = Vec::new();
+        let mut current: Option<(String, Vec<(String, String)>)> = None;
+        let mut finish = |current: Option<(String, Vec<(String, String)>)>| {
+            if let Some((id, mut props)) = current {
+                let pos = props.iter().position(|(k, _)| k == "driver").unwrap();
+                let (_, driver) = props.remove(pos);
+                devices.push(normalize_device(&id, &driver, &mut props.into_iter()));
+            }
+        };
+        for line in cfg.lines().map(str::trim) {
+            if line.is_empty() || line.starts_with('#') {
+                continue;
+            }
+            if let Some(id) = line.strip_prefix("[device \"") {
+                finish(current.take());
+                current = Some((id.trim_end_matches("\"]").to_string(), Vec::new()));
+            } else if let Some((key, value)) = line.split_once('=') {
+                let value = value.trim().trim_matches('"').to_string();
+                current
+                    .as_mut()
+                    .unwrap()
+                    .1
+                    .push((key.trim().to_string(), value));
+            }
+        }
+        finish(current);
+        devices
+    }
+
+    #[test]
+    /// The q35 chipset devices have to match what the machine config file of
+    /// qemu-server created before, as they are part of the guest's hardware.
+    fn test_q35_chipset_matches_cfg() {
+        let mut hw = hw_config(true, None, 0, false);
+        hw.machine_version = (11, 1, Some(1));
+        let res = pci_bus_devices(&hw).expect("valid machine");
+        let generated: Vec<_> = res
+            .chunks(2)
+            .take(Q35_CHIPSET_DEVICES.len())
+            .map(|device| {
+                assert_eq!(device[0], "-device");
+                let mut parts = device[1].split(',');
+                let driver = parts.next().unwrap();
+                let mut props = parts.map(|part| {
+                    let (key, value) = part.split_once('=').unwrap();
+                    (key.to_string(), value.to_string())
+                });
+                let id = props.next().unwrap();
+                assert_eq!(id.0, "id");
+                normalize_device(&id.1, driver, &mut props)
+            })
+            .collect();
+        let expected = parse_qemu_cfg(PVE_Q35_4_0_CFG);
+        assert_eq!(expected.len(), Q35_CHIPSET_DEVICES.len());
+        assert_eq!(generated, expected);
+    }
+
+    /// Copy of qemu-server's `pve-q35-4.0.cfg`.
+    const PVE_Q35_4_0_CFG: &str = include_str!(concat!(
+        env!("CARGO_MANIFEST_DIR"),
+        "/test-data/pve-q35-4.0.cfg"
+    ));
 }
diff --git a/pve-qemu-server-pci/src/lib.rs b/pve-qemu-server-pci/src/lib.rs
index fc982ce..a5d82a4 100644
--- a/pve-qemu-server-pci/src/lib.rs
+++ b/pve-qemu-server-pci/src/lib.rs
@@ -11,7 +11,7 @@
 pub mod constants;
 
 mod hardware_config;
-pub use hardware_config::HardwareConfig;
+pub use hardware_config::{HardwareConfig, HostpcieDevice};
 
 mod types;
 pub use types::*;
diff --git a/pve-qemu-server-pci/src/types/layout.rs b/pve-qemu-server-pci/src/types/layout.rs
index 889b961..91b1d78 100644
--- a/pve-qemu-server-pci/src/types/layout.rs
+++ b/pve-qemu-server-pci/src/types/layout.rs
@@ -1,4 +1,4 @@
-use std::collections::VecDeque;
+use std::collections::{HashSet, VecDeque};
 
 use crate::constants::{FUNCTIONS_PER_DEVICE, SLOTS_PER_BUS, USABLE_SLOTS_PER_BUS};
 use crate::{Bus, Device, LayoutError, PciAddress, PveBridge};
@@ -48,6 +48,46 @@ pub struct Layout<'a> {
 }
 
 impl Layout<'_> {
+    /// Returns the root ports `devices` are attached to in layout order, plus
+    /// the one on function 0 of each used slot, as guests only look for further
+    /// functions if function 0 exists.
+    pub fn root_ports(&self, devices: &[Device]) -> Vec<RootPort> {
+        let attached: HashSet<PciAddress> = devices
+            .iter()
+            .filter_map(|device| self.find_device(device).ok())
+            .collect();
+        let is_used = |port: &RootPort| attached.contains(&PciAddress::new(port.id, 0, 0));
+
+        let ports: Vec<RootPort> = self
+            .iter()
+            .filter_map(|used| match used.device {
+                #[expect(deprecated)]
+                Device::RootPort(id @ (Bus::RootPort(index) | Bus::LegacyRootPort(index))) => {
+                    Some(RootPort {
+                        addr: used.addr,
+                        id,
+                        index,
+                    })
+                }
+                _ => None,
+            })
+            .collect();
+        let used_slots: HashSet<(Bus, u8)> = ports
+            .iter()
+            .filter(|port| is_used(port))
+            .map(|port| (port.addr.bus, port.addr.device))
+            .collect();
+
+        ports
+            .into_iter()
+            .filter(|port| {
+                is_used(port)
+                    || (port.addr.function == 0
+                        && used_slots.contains(&(port.addr.bus, port.addr.device)))
+            })
+            .collect()
+    }
+
     /// Looks up the address of a device in this layout.
     ///
     /// The first match wins, so the order of the layout is significant: a
@@ -68,6 +108,27 @@ impl Layout<'_> {
     }
 }
 
+/// A PCIe root port of a layout.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct RootPort {
+    pub addr: PciAddress,
+    /// The bus the root port forms.
+    pub id: Bus,
+    /// The number of the root port, also used as port and chassis number.
+    pub index: u8,
+}
+
+impl RootPort {
+    /// Renders the QEMU `-device` argument for this root port.
+    pub fn to_qemu_device(&self) -> String {
+        let RootPort { addr, id, index } = self;
+        format!(
+            "pcie-root-port,id={id},addr={:02x}.{},x-speed=16,x-width=32,multifunction=on,bus={},port={index},chassis={index}",
+            addr.device, addr.function, addr.bus,
+        )
+    }
+}
+
 /// The address a layout assigns to a device.
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
 pub struct AddressAssignment {
diff --git a/pve-qemu-server-pci/src/types/mod.rs b/pve-qemu-server-pci/src/types/mod.rs
index 3847745..1605066 100644
--- a/pve-qemu-server-pci/src/types/mod.rs
+++ b/pve-qemu-server-pci/src/types/mod.rs
@@ -8,4 +8,4 @@ mod pci_address;
 pub use pci_address::PciAddress;
 
 mod layout;
-pub use layout::{AddressAssignment, AddressAssignments, Function, Layout, Slot};
+pub use layout::{AddressAssignment, AddressAssignments, Function, Layout, RootPort, Slot};
diff --git a/pve-qemu-server-pci/test-data/pve-q35-4.0.cfg b/pve-qemu-server-pci/test-data/pve-q35-4.0.cfg
new file mode 100644
index 0000000..c931417
--- /dev/null
+++ b/pve-qemu-server-pci/test-data/pve-q35-4.0.cfg
@@ -0,0 +1,163 @@
+[device "ehci"]
+  driver = "ich9-usb-ehci1"
+  multifunction = "on"
+  bus = "pcie.0"
+  addr = "1d.7"
+
+[device "uhci-1"]
+  driver = "ich9-usb-uhci1"
+  multifunction = "on"
+  bus = "pcie.0"
+  addr = "1d.0"
+  masterbus = "ehci.0"
+  firstport = "0"
+
+[device "uhci-2"]
+  driver = "ich9-usb-uhci2"
+  multifunction = "on"
+  bus = "pcie.0"
+  addr = "1d.1"
+  masterbus = "ehci.0"
+  firstport = "2"
+
+[device "uhci-3"]
+  driver = "ich9-usb-uhci3"
+  multifunction = "on"
+  bus = "pcie.0"
+  addr = "1d.2"
+  masterbus = "ehci.0"
+  firstport = "4"
+
+[device "ehci-2"]
+  driver = "ich9-usb-ehci2"
+  multifunction = "on"
+  bus = "pcie.0"
+  addr = "1a.7"
+
+[device "uhci-4"]
+  driver = "ich9-usb-uhci4"
+  multifunction = "on"
+  bus = "pcie.0"
+  addr = "1a.0"
+  masterbus = "ehci-2.0"
+  firstport = "0"
+
+[device "uhci-5"]
+  driver = "ich9-usb-uhci5"
+  multifunction = "on"
+  bus = "pcie.0"
+  addr = "1a.1"
+  masterbus = "ehci-2.0"
+  firstport = "2"
+
+[device "uhci-6"]
+  driver = "ich9-usb-uhci6"
+  multifunction = "on"
+  bus = "pcie.0"
+  addr = "1a.2"
+  masterbus = "ehci-2.0"
+  firstport = "4"
+
+# FIXME: Remove this audio0 device at the next possible time
+#     see: https://pve.proxmox.com/pipermail/pve-devel/2019-July/038417.html
+#          https://pve.proxmox.com/pipermail/pve-devel/2019-July/038428.html
+[device "audio0"]
+  driver = "ich9-intel-hda"
+  bus = "pcie.0"
+  addr = "1b.0"
+
+
+[device "ich9-pcie-port-1"]
+  driver = "pcie-root-port"
+  x-speed = "16"
+  x-width = "32"
+  multifunction = "on"
+  bus = "pcie.0"
+  addr = "1c.0"
+  port = "1"
+  chassis = "1"
+
+[device "ich9-pcie-port-2"]
+  driver = "pcie-root-port"
+  x-speed = "16"
+  x-width = "32"
+  multifunction = "on"
+  bus = "pcie.0"
+  addr = "1c.1"
+  port = "2"
+  chassis = "2"
+
+[device "ich9-pcie-port-3"]
+  driver = "pcie-root-port"
+  x-speed = "16"
+  x-width = "32"
+  multifunction = "on"
+  bus = "pcie.0"
+  addr = "1c.2"
+  port = "3"
+  chassis = "3"
+
+[device "ich9-pcie-port-4"]
+  driver = "pcie-root-port"
+  x-speed = "16"
+  x-width = "32"
+  multifunction = "on"
+  bus = "pcie.0"
+  addr = "1c.3"
+  port = "4"
+  chassis = "4"
+
+##
+# Example PCIe switch with two downstream ports
+#
+#[device "pcie-switch-upstream-port-1"]
+#  driver = "x3130-upstream"
+#  bus = "ich9-pcie-port-4"
+#  addr = "00.0"
+#
+#[device "pcie-switch-downstream-port-1-1"]
+#  driver = "xio3130-downstream"
+#  multifunction = "on"
+#  bus = "pcie-switch-upstream-port-1"
+#  addr = "00.0"
+#  port = "1"
+#  chassis = "5"
+#
+#[device "pcie-switch-downstream-port-1-2"]
+#  driver = "xio3130-downstream"
+#  multifunction = "on"
+#  bus = "pcie-switch-upstream-port-1"
+#  addr = "00.1"
+#  port = "1"
+#  chassis = "6"
+
+
+
+[device "pcidmi"]
+  driver = "i82801b11-bridge"
+  bus = "pcie.0"
+  addr = "1e.0"
+
+[device "pci.0"]
+  driver = "pci-bridge"
+  bus = "pcidmi"
+  addr = "1.0"
+  chassis_nr = "1"
+
+[device "pci.1"]
+  driver = "pci-bridge"
+  bus = "pcidmi"
+  addr = "2.0"
+  chassis_nr = "2"
+
+[device "pci.2"]
+  driver = "pci-bridge"
+  bus = "pcidmi"
+  addr = "3.0"
+  chassis_nr = "3"
+
+[device "pci.3"]
+  driver = "pci-bridge"
+  bus = "pcidmi"
+  addr = "4.0"
+  chassis_nr = "4"
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 12+ messages in thread

* [PATCH pve-qemu-server-rs v2 4/9] pci: layout: add grouped PCI and PCIe layouts
  2026-10-15  8:10 [RFC proxmox-perl-rs/qemu-server/qemu-server-rs v2 0/9] pci-handling rewrite (part 1/2/3) Dominik Csapak
                   ` (2 preceding siblings ...)
  2026-10-15  8:10 ` [PATCH pve-qemu-server-rs v2 3/9] pci: legacy: generate q35 chipset devices and hostpcie root ports Dominik Csapak
@ 2026-10-15  8:10 ` Dominik Csapak
  2026-10-15  8:10 ` [PATCH proxmox-perl-rs v2 5/9] pve: add bindings for `pve-qemu-server-pci` crate Dominik Csapak
                   ` (4 subsequent siblings)
  8 siblings, 0 replies; 12+ messages in thread
From: Dominik Csapak @ 2026-10-15  8:10 UTC (permalink / raw)
  To: pve-devel

The legacy layout grew over many releases and it shows: devices of one
kind are spread over several buses, the bus a device lands on depends on
how many devices of other kinds exist, and the remaining free slots are
scattered. That makes it hard to raise any of the per-kind limits
without further scattering the types over the available addresses.

Add a second set of layouts that gives every device kind its own set of
bridges instead. A device's address then only depends on its own index,
so raising a limit adds bridges at the end rather than shifting anything
that exists, and the built-in devices move to a bridge of their own to
keep the root bus free.

The new `layout` field of `HardwareConfig` selects which set is used,
so `pci_bridge_for_device` now takes the config too. Since the layout
is part of the guest ABI, the grouped one is only intended for new
guests.

`pci_bus_devices` returns its bridges and the hostpcie root ports,
with the same function 0 handling as for the legacy layout.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 pve-qemu-server-pci/src/constants.rs       |   8 +
 pve-qemu-server-pci/src/error.rs           |   3 +
 pve-qemu-server-pci/src/hardware_config.rs |   9 +-
 pve-qemu-server-pci/src/layout/grouped.rs  | 469 +++++++++++++++++++++
 pve-qemu-server-pci/src/layout/legacy.rs   |   1 +
 pve-qemu-server-pci/src/layout/mod.rs      |   2 +
 pve-qemu-server-pci/src/lib.rs             |  49 ++-
 pve-qemu-server-pci/src/types/mod.rs       |   3 +
 8 files changed, 538 insertions(+), 6 deletions(-)
 create mode 100644 pve-qemu-server-pci/src/layout/grouped.rs

diff --git a/pve-qemu-server-pci/src/constants.rs b/pve-qemu-server-pci/src/constants.rs
index ea6db21..29e5d87 100644
--- a/pve-qemu-server-pci/src/constants.rs
+++ b/pve-qemu-server-pci/src/constants.rs
@@ -1,5 +1,13 @@
 //! Limits the layouts are built around.
 
+// Upper bounds for the per-guest device counts the layouts reserve slots for,
+// re-exposing the limits enforced by the qemu-server configuration schema.
+pub(crate) const MAX_NET_DEVICES: u8 = pve_api_types::QemuConfigNetArray::MAX as u8;
+pub(crate) const MAX_SCSI_DEVICES: u8 = pve_api_types::QemuConfigScsiArray::MAX as u8;
+pub(crate) const MAX_VIRTIO_BLK_DEVICES: u8 = pve_api_types::QemuConfigVirtioArray::MAX as u8;
+pub(crate) const MAX_VIRTIOFS_DEVICES: u8 = pve_api_types::QemuConfigVirtiofsArray::MAX as u8;
+pub(crate) const MAX_HOSTPCI_DEVICES: u8 = pve_api_types::QemuConfigHostpciArray::MAX as u8;
+
 /// Number of slots on a PCI bus. Slot 0x00 is always taken by the bridge
 /// itself, so only [`USABLE_SLOTS_PER_BUS`] of them can hold a device.
 pub(crate) const SLOTS_PER_BUS: usize = 32;
diff --git a/pve-qemu-server-pci/src/error.rs b/pve-qemu-server-pci/src/error.rs
index 5c4f7de..d67b3df 100644
--- a/pve-qemu-server-pci/src/error.rs
+++ b/pve-qemu-server-pci/src/error.rs
@@ -10,6 +10,8 @@ pub enum LayoutError {
     InvalidConfigId,
     /// The device is not part of the layout it was looked up in.
     NoAddressFound(Device),
+    /// The selected layout is not known.
+    InvalidLayout,
 }
 
 impl Display for LayoutError {
@@ -17,6 +19,7 @@ impl Display for LayoutError {
         match self {
             LayoutError::InvalidConfigId => write!(f, "invalid configuration id"),
             LayoutError::NoAddressFound(device) => write!(f, "address '{device}' not found"),
+            LayoutError::InvalidLayout => write!(f, "invalid layout"),
         }
     }
 }
diff --git a/pve-qemu-server-pci/src/hardware_config.rs b/pve-qemu-server-pci/src/hardware_config.rs
index 677fe0a..95bcda6 100644
--- a/pve-qemu-server-pci/src/hardware_config.rs
+++ b/pve-qemu-server-pci/src/hardware_config.rs
@@ -4,7 +4,7 @@ use std::sync::atomic::AtomicU16;
 
 use pve_api_types::{ClusterResourceHostArch as Arch, QemuConfigOstype, QemuConfigScsihw};
 
-use crate::Device;
+use crate::{Device, PciLayout};
 
 #[derive(Default, Clone, Debug, PartialEq)]
 pub enum Mainboard {
@@ -59,6 +59,9 @@ pub struct HardwareConfig {
     pub machine_version: (u16, u16, Option<u16>),
     /// The recorded required `+pveX` version
     pub required_pve_version: Arc<AtomicU16>,
+    /// The layouts the guest's addresses are taken from. They are part of
+    /// the guest's ABI, so this must not change for an existing guest.
+    pub layout: PciLayout,
     /// The configured `hostpcie` devices, keyed by their index, which decide
     /// what root ports are needed.
     pub hostpcie_devices: BTreeMap<u8, HostpcieDevice>,
@@ -81,6 +84,7 @@ impl HardwareConfig {
         major: u16,
         minor: u16,
         pve: Option<u16>,
+        layout: &str,
     ) -> Self {
         let arch = match arch.parse().unwrap_or(Arch::X8664) {
             Arch::UnknownEnumValue(_) => Arch::X8664,
@@ -103,6 +107,7 @@ impl HardwareConfig {
             ostype,
             machine_version: (major, minor, pve),
             required_pve_version: Arc::new(AtomicU16::new(0)),
+            layout: layout.parse().unwrap_or(PciLayout::Legacy),
             hostpcie_devices: BTreeMap::new(),
         }
     }
@@ -123,7 +128,7 @@ impl HardwareConfig {
     /// get them from [`crate::pci_bus_devices`], so older guests stay unchanged
     /// for live migration.
     pub fn uses_q35_config_file(&self) -> bool {
-        self.is_q35() && !self.version_at_least(11, 1, Some(1))
+        self.is_q35() && self.layout == PciLayout::Legacy && !self.version_at_least(11, 1, Some(1))
     }
 
     /// Whether each SCSI disk gets its own controller, which needs the extra
diff --git a/pve-qemu-server-pci/src/layout/grouped.rs b/pve-qemu-server-pci/src/layout/grouped.rs
new file mode 100644
index 0000000..5c5a8cc
--- /dev/null
+++ b/pve-qemu-server-pci/src/layout/grouped.rs
@@ -0,0 +1,469 @@
+use crate::constants::{
+    MAX_HOSTPCI_DEVICES, MAX_NET_DEVICES, MAX_SCSI_DEVICES, MAX_VIRTIO_BLK_DEVICES,
+    MAX_VIRTIOFS_DEVICES, SLOTS_PER_BUS,
+};
+use crate::layout::bridge_slots;
+use crate::{
+    Bus, Device, Function, HardwareConfig, Layout, LayoutError, PciAddress, PveBridge, Slot,
+};
+
+/// The guest's built-in devices, which all live on a bridge of their own so
+/// that the root bus stays free for the per-kind bridges.
+///
+/// NOTE: only append here, don't reorder. The order implies the address, which
+/// has to stay stable.
+const BUILTIN_DEVICES: [Slot; SLOTS_PER_BUS] = bridge_slots(&[
+    single_device!(Ahci),
+    single_device!(Balloon),
+    single_device!(XhciController(0)),
+    single_device!(GuestAgent),
+    single_device!(SpiceSerial),
+    single_device!(Rng),
+    single_device!(Audio),
+    single_device!(Watchdog),
+    single_device!(Ivshmem),
+]);
+
+/// The layout for guests with a PCI root bus.
+pub static PCI_LAYOUT: Layout = Layout {
+    pcie: false,
+    root: bridge_slots(&[
+        Slot::Reserved,
+        Slot::Multi(&[
+            Function::Device(Device::Vga(0)),
+            Function::Device(Device::Vga(1)),
+            Function::Device(Device::Vga(2)),
+            Function::Device(Device::Vga(3)),
+            Function::Unused,
+            Function::Unused,
+            Function::Unused,
+            Function::Unused,
+        ]),
+        Slot::Single(Function::Device(Device::Viommu)),
+        Slot::Single(Function::FixedBridge(Bus::Sys(0), &BUILTIN_DEVICES)),
+        Slot::DynamicBridges {
+            kind: PveBridge::VirtioFs,
+            max: MAX_VIRTIOFS_DEVICES,
+        },
+        Slot::DynamicBridges {
+            kind: PveBridge::Net,
+            max: MAX_NET_DEVICES,
+        },
+        Slot::DynamicBridges {
+            kind: PveBridge::Scsi,
+            max: MAX_SCSI_DEVICES,
+        },
+        Slot::DynamicBridges {
+            kind: PveBridge::VirtioBlk,
+            max: MAX_VIRTIO_BLK_DEVICES,
+        },
+        Slot::DynamicBridges {
+            kind: PveBridge::Hostpci,
+            max: MAX_HOSTPCI_DEVICES,
+        },
+    ]),
+};
+
+/// The layout for guests with a PCIe root bus.
+///
+/// Identical to [`PCI_LAYOUT`] except for the root bus type and the root ports
+/// for passed through PCIe devices, which a PCI machine cannot have.
+pub static PCIE_LAYOUT: Layout = Layout {
+    pcie: true,
+    root: [
+        Slot::Reserved,
+        Slot::Reserved,
+        Slot::Multi(&[
+            Function::Device(Device::Vga(0)),
+            Function::Device(Device::Vga(1)),
+            Function::Device(Device::Vga(2)),
+            Function::Device(Device::Vga(3)),
+            Function::Unused,
+            Function::Unused,
+            Function::Unused,
+            Function::Unused,
+        ]),
+        Slot::Single(Function::Device(Device::Viommu)),
+        Slot::Single(Function::FixedBridge(Bus::Sys(0), &BUILTIN_DEVICES)),
+        Slot::DynamicBridges {
+            kind: PveBridge::VirtioFs,
+            max: MAX_VIRTIOFS_DEVICES,
+        },
+        Slot::DynamicBridges {
+            kind: PveBridge::Net,
+            max: MAX_NET_DEVICES,
+        },
+        Slot::DynamicBridges {
+            kind: PveBridge::Scsi,
+            max: MAX_SCSI_DEVICES,
+        },
+        Slot::DynamicBridges {
+            kind: PveBridge::VirtioBlk,
+            max: MAX_VIRTIO_BLK_DEVICES,
+        },
+        Slot::DynamicBridges {
+            kind: PveBridge::Hostpci,
+            max: MAX_HOSTPCI_DEVICES,
+        },
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Multi(&[
+            Function::RootPort(Bus::RootPort(0), Device::Hostpcie(0)),
+            Function::RootPort(Bus::RootPort(1), Device::Hostpcie(1)),
+            Function::RootPort(Bus::RootPort(2), Device::Hostpcie(2)),
+            Function::RootPort(Bus::RootPort(3), Device::Hostpcie(3)),
+            Function::RootPort(Bus::RootPort(4), Device::Hostpcie(4)),
+            Function::RootPort(Bus::RootPort(5), Device::Hostpcie(5)),
+            Function::RootPort(Bus::RootPort(6), Device::Hostpcie(6)),
+            Function::RootPort(Bus::RootPort(7), Device::Hostpcie(7)),
+        ]),
+        Slot::Multi(&[
+            Function::RootPort(Bus::RootPort(8), Device::Hostpcie(8)),
+            Function::RootPort(Bus::RootPort(9), Device::Hostpcie(9)),
+            Function::RootPort(Bus::RootPort(10), Device::Hostpcie(10)),
+            Function::RootPort(Bus::RootPort(11), Device::Hostpcie(11)),
+            Function::RootPort(Bus::RootPort(12), Device::Hostpcie(12)),
+            Function::RootPort(Bus::RootPort(13), Device::Hostpcie(13)),
+            Function::RootPort(Bus::RootPort(14), Device::Hostpcie(14)),
+            Function::RootPort(Bus::RootPort(15), Device::Hostpcie(15)),
+        ]),
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Reserved, // ICH9 South bridge
+    ],
+};
+
+fn layout(pcie: bool) -> &'static Layout<'static> {
+    if pcie { &PCIE_LAYOUT } else { &PCI_LAYOUT }
+}
+
+pub(crate) fn address(hw: &HardwareConfig, id: &str) -> Result<PciAddress, LayoutError> {
+    let layout = layout(hw.has_pcie());
+    layout.find_device(&id.parse()?)
+}
+
+pub(crate) fn pci_bridge_for_device(hw: &HardwareConfig, id: &str) -> Option<Bus> {
+    let addr = address(hw, id).ok()?;
+    Some(addr.bus)
+}
+
+pub(crate) fn pci_bus_devices(hw: &HardwareConfig) -> Result<Vec<String>, LayoutError> {
+    let mut bridges = Vec::new();
+    let layout = layout(hw.has_pcie());
+    let mut chassis_nr = 1;
+    for bridge in layout
+        .iter()
+        .filter(|d| matches!(d.device, Device::Bridge(_)))
+    {
+        let addr = bridge.addr;
+        let (model, id) = match bridge.device {
+            Device::Bridge(bus) => (bus.bridge_model(addr.bus), bus),
+            _ => continue,
+        };
+        let Some(model) = model else {
+            continue;
+        };
+        let mf = if addr.function == 0 {
+            ",multifunction=on"
+        } else {
+            ""
+        };
+
+        let chassis = if model == "pci-bridge" {
+            let text = format!(",chassis_nr={chassis_nr}");
+            chassis_nr += 1;
+            text
+        } else {
+            String::new()
+        };
+        bridges.push("-device".to_string());
+        bridges.push(format!(
+            "{model},id={id}{chassis}{}{mf}",
+            addr.to_qemu_addr()
+        ));
+    }
+
+    for port in layout.root_ports(&hw.hostpcie()) {
+        bridges.push("-device".to_string());
+        bridges.push(port.to_qemu_device());
+    }
+
+    Ok(bridges)
+}
+
+#[cfg(test)]
+mod test {
+    use std::collections::{HashMap, HashSet};
+
+    use strum::VariantArray;
+
+    use super::{PCI_LAYOUT, PCIE_LAYOUT};
+
+    use super::pci_bus_devices;
+    use crate::constants::{
+        MAX_HOSTPCI_DEVICES, MAX_NET_DEVICES, MAX_SCSI_DEVICES, MAX_VIRTIO_BLK_DEVICES,
+        MAX_VIRTIOFS_DEVICES,
+    };
+    use crate::layout::legacy::test::LEGACY_ADDRS;
+    use crate::{
+        Bus, Device, DeviceDiscriminants, HardwareConfig, HostpcieDevice, Layout, PciAddress,
+        PveBridge,
+    };
+
+    fn check_device(device: Device, res: Option<PciAddress>) {
+        assert_eq!(PCI_LAYOUT.find_device(&device).ok(), res);
+    }
+
+    #[test]
+    fn pci_find_devices_on_dynamic_bridge() {
+        check_device(
+            Device::Net(0),
+            Some(PciAddress::new(PveBridge::Net.new_bus(0), 1, 0)),
+        );
+        check_device(
+            Device::Net(30),
+            Some(PciAddress::new(PveBridge::Net.new_bus(0), 31, 0)),
+        );
+        check_device(
+            Device::Net(31),
+            Some(PciAddress::new(PveBridge::Net.new_bus(1), 1, 0)),
+        );
+        check_device(Device::Net(100), None);
+    }
+
+    #[test]
+    fn pci_find_devices() {
+        check_device(Device::Vga(0), Some(PciAddress::new(Bus::Pci(0), 2, 0)));
+        check_device(Device::Vga(3), Some(PciAddress::new(Bus::Pci(0), 2, 3)));
+        check_device(Device::Audio, Some(PciAddress::new(Bus::Sys(0), 7, 0)));
+    }
+
+    fn test_all_devices_on_layout(layout: &Layout, exclude_devices: &[DeviceDiscriminants]) {
+        let mut set: HashSet<String> = HashSet::new();
+
+        let mut assert_insert = |device: &Device| {
+            let addr = layout
+                .find_device(device)
+                .unwrap_or_else(|_| panic!("could not find address for device {device:?}"));
+            assert!(set.insert(addr.to_qemu_addr()))
+        };
+
+        for variant in DeviceDiscriminants::VARIANTS {
+            if exclude_devices.contains(variant) {
+                continue;
+            }
+            match variant {
+                DeviceDiscriminants::Vga => {
+                    for i in 0..4 {
+                        assert_insert(&Device::Vga(i));
+                    }
+                }
+                DeviceDiscriminants::Viommu => {
+                    assert_insert(&Device::Viommu);
+                }
+                DeviceDiscriminants::Net => {
+                    for i in 0..MAX_NET_DEVICES {
+                        assert_insert(&Device::Net(i));
+                    }
+                }
+                DeviceDiscriminants::ScsiController => {
+                    for i in 0..MAX_SCSI_DEVICES {
+                        assert_insert(&Device::ScsiController(i));
+                    }
+                }
+                DeviceDiscriminants::VirtioBlk => {
+                    for i in 0..MAX_VIRTIO_BLK_DEVICES {
+                        assert_insert(&Device::VirtioBlk(i));
+                    }
+                }
+                DeviceDiscriminants::XhciController => {
+                    assert_insert(&Device::XhciController(0));
+                }
+                DeviceDiscriminants::Hostpci => {
+                    for i in 0..MAX_HOSTPCI_DEVICES {
+                        assert_insert(&Device::Hostpci(i));
+                    }
+                }
+                DeviceDiscriminants::Hostpcie => {
+                    for i in 0..MAX_HOSTPCI_DEVICES {
+                        assert_insert(&Device::Hostpcie(i));
+                    }
+                }
+                DeviceDiscriminants::VirtioFs => {
+                    for i in 0..MAX_VIRTIOFS_DEVICES {
+                        assert_insert(&Device::VirtioFs(i));
+                    }
+                }
+                DeviceDiscriminants::Ich9Ehci
+                | DeviceDiscriminants::Ich9Uhci
+                | DeviceDiscriminants::Ich9Audio => {
+                    // only part of the legacy q35 chipset
+                }
+                DeviceDiscriminants::Piix3Controller => {
+                    // does not  exist in modern layout
+                }
+                DeviceDiscriminants::Bridge | DeviceDiscriminants::RootPort => {
+                    // TODO: what to test?
+                }
+                other => {
+                    assert_insert(&(*other).try_into().expect("invalid variant"));
+                }
+            }
+        }
+    }
+
+    #[test]
+    /// Tests that each slot is only given once
+    fn pci_test_no_duplicate_addresses() {
+        test_all_devices_on_layout(&PCI_LAYOUT, &[DeviceDiscriminants::Hostpcie]);
+    }
+
+    #[test]
+    /// Tests that each slot is only given once
+    fn pcie_test_no_duplicate_addresses() {
+        test_all_devices_on_layout(&PCIE_LAYOUT, &[]);
+    }
+
+    #[test]
+    fn test_no_duplicate_device() {
+        let mut set = HashSet::new();
+        for device in PCI_LAYOUT.iter() {
+            if !set.insert(device.device) {
+                panic!("device {:?} added twice in PCI_LAYOUT", device.device)
+            }
+        }
+        let mut set = HashSet::new();
+        for device in PCIE_LAYOUT.iter() {
+            if !set.insert(device.device) {
+                panic!("device {:?} added twice in PCIE_LAYOUT", device.device)
+            }
+        }
+    }
+
+    #[test]
+    fn test_legacy_ids() {
+        let mut set: HashMap<(&str, String), &'static str> = HashMap::new();
+
+        let mut assert_insert = |layout: &Layout, layout_name: &'static str, id: &'static str| {
+            let device = id
+                .parse()
+                .unwrap_or_else(|_| panic!("could not parse {id}"));
+            let addr = layout
+                .find_device(&device)
+                .unwrap_or_else(|_| panic!("could not find address for device {id} {device:?}"));
+            let addr_string = addr.to_qemu_addr();
+            if let Some(old) = set.insert((layout_name, addr_string), id) {
+                panic!("{layout_name}: {id} conflicts with {old} on {addr:?}");
+            }
+        };
+
+        let skipped = &[
+            "ehci", // legacy entries not found on new layouts
+            "piix3",
+            "legacy-igd",
+            "pci.1",
+            "pci.2",
+            "pci.2-igd",
+            "pci.3",
+            "pci.4",
+            "scsihw0", // are handled by virtioscsiX
+            "scsihw1",
+            "scsihw2",
+            "scsihw3",
+            "scsihw4",
+        ];
+        for (id, _, _) in LEGACY_ADDRS {
+            if skipped.contains(&id) {
+                continue;
+            }
+            assert_insert(&PCI_LAYOUT, "pci", id);
+            assert_insert(&PCIE_LAYOUT, "pcie", id);
+        }
+    }
+
+    #[test]
+    /// QEMU needs to be told about multifunction slots, so the flag has to be
+    /// set on function zero of every slot that uses more than one function.
+    fn test_multifunction() {
+        let multifunction: HashMap<Device, bool> = PCI_LAYOUT
+            .iter()
+            .map(|used| (used.device, used.multifunction))
+            .collect();
+
+        // the VGA devices share one slot
+        assert!(multifunction[&Device::Vga(0)]);
+        assert!(!multifunction[&Device::Vga(1)]);
+
+        // 32 net devices need two bridges, which share one slot
+        assert!(multifunction[&Device::Bridge(PveBridge::Net.new_bus(0))]);
+        assert!(!multifunction[&Device::Bridge(PveBridge::Net.new_bus(1))]);
+
+        // 31 SCSI controllers fit on a single bridge
+        assert!(!multifunction[&Device::Bridge(PveBridge::Scsi.new_bus(0))]);
+
+        // devices on a bridge each get their own slot
+        assert!(!multifunction[&Device::Net(0)]);
+    }
+
+    #[test]
+    fn test_root_ports() {
+        let test = |hostpcie: &[u8], expected: &[(u8, &str)]| {
+            let mut hw = HardwareConfig::new(
+                "x86_64",
+                "pc-q35-10.0+pve1".into(),
+                None,
+                0,
+                false,
+                None,
+                10,
+                0,
+                Some(1),
+                "grouped",
+            );
+            hw.hostpcie_devices = hostpcie
+                .iter()
+                .map(|&index| (index, HostpcieDevice::default()))
+                .collect();
+            let expected: Vec<String> = expected
+                .iter()
+                .flat_map(|(index, addr)| {
+                    [
+                        "-device".to_string(),
+                        format!(
+                            "pcie-root-port,id=pve.root-port{index},addr={addr},x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port={index},chassis={index}"
+                        ),
+                    ]
+                })
+                .collect();
+            let res = pci_bus_devices(&hw).expect("valid machine");
+            let res: Vec<String> = res
+                .chunks(2)
+                .filter(|device| device[1].starts_with("pcie-root-port"))
+                .flatten()
+                .cloned()
+                .collect();
+            assert_eq!(res, expected, "for {hostpcie:?}");
+        };
+
+        test(&[], &[]);
+        test(&[0, 1], &[(0, "0e.0"), (1, "0e.1")]);
+        // function 0 of a used slot has to exist for guests to see the others
+        test(&[3], &[(0, "0e.0"), (3, "0e.3")]);
+        test(&[15, 9], &[(8, "0f.0"), (9, "0f.1"), (15, "0f.7")]);
+    }
+}
diff --git a/pve-qemu-server-pci/src/layout/legacy.rs b/pve-qemu-server-pci/src/layout/legacy.rs
index d89c0bf..e594ab9 100644
--- a/pve-qemu-server-pci/src/layout/legacy.rs
+++ b/pve-qemu-server-pci/src/layout/legacy.rs
@@ -837,6 +837,7 @@ pub(crate) mod test {
             9,
             2,
             None,
+            "legacy",
         )
     }
 
diff --git a/pve-qemu-server-pci/src/layout/mod.rs b/pve-qemu-server-pci/src/layout/mod.rs
index 5439c17..9a637ea 100644
--- a/pve-qemu-server-pci/src/layout/mod.rs
+++ b/pve-qemu-server-pci/src/layout/mod.rs
@@ -13,6 +13,8 @@ macro_rules! single_device {
 
 pub(crate) mod legacy;
 
+pub mod grouped;
+
 /// Constructs a correctly sized list of bridge slots, starting at address 0x01
 /// because 0x00 is always taken by the bridge itself.
 ///
diff --git a/pve-qemu-server-pci/src/lib.rs b/pve-qemu-server-pci/src/lib.rs
index a5d82a4..5417fa5 100644
--- a/pve-qemu-server-pci/src/lib.rs
+++ b/pve-qemu-server-pci/src/lib.rs
@@ -21,10 +21,45 @@ pub use error::LayoutError;
 
 pub mod layout;
 
+/// The set of layouts a guest's addresses are taken from.
+#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
+pub enum PciLayout {
+    /// The layouts reproducing the addresses of the Perl implementation.
+    #[default]
+    Legacy,
+    /// The layouts giving every device kind its own set of bridges, see
+    /// [`layout::grouped`].
+    Grouped,
+}
+
+impl std::str::FromStr for PciLayout {
+    type Err = LayoutError;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        match s {
+            "legacy" => Ok(Self::Legacy),
+            "grouped" => Ok(Self::Grouped),
+            _ => Err(LayoutError::InvalidLayout),
+        }
+    }
+}
+
+impl std::fmt::Display for PciLayout {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.write_str(match self {
+            PciLayout::Legacy => "legacy",
+            PciLayout::Grouped => "grouped",
+        })
+    }
+}
+
 /// Returns the number of the PCI bridge `id` sits on, or `None` if it is on
 /// the root bus or not a known device.
-pub fn pci_bridge_for_device(id: &str) -> Option<Bus> {
-    layout::legacy::pci_bridge_for_device(id)
+pub fn pci_bridge_for_device(hw: &HardwareConfig, id: &str) -> Option<Bus> {
+    match hw.layout {
+        PciLayout::Legacy => layout::legacy::pci_bridge_for_device(id),
+        PciLayout::Grouped => layout::grouped::pci_bridge_for_device(hw, id),
+    }
 }
 
 /// Returns the QEMU arguments for all devices that form the buses `hw`
@@ -33,10 +68,16 @@ pub fn pci_bridge_for_device(id: &str) -> Option<Bus> {
 /// They are ordered so that every bus is created before the devices on it,
 /// and have to be passed before any other device that sits on these buses.
 pub fn pci_bus_devices(hw: &HardwareConfig) -> Result<Vec<String>, LayoutError> {
-    layout::legacy::pci_bus_devices(hw)
+    match hw.layout {
+        PciLayout::Legacy => layout::legacy::pci_bus_devices(hw),
+        PciLayout::Grouped => layout::grouped::pci_bus_devices(hw),
+    }
 }
 
 /// Returns the address of the device with the configuration id `id`.
 pub fn address(hw: &HardwareConfig, id: &str) -> Result<PciAddress, LayoutError> {
-    layout::legacy::address(hw, id)
+    match hw.layout {
+        PciLayout::Legacy => layout::legacy::address(hw, id),
+        PciLayout::Grouped => layout::grouped::address(hw, id),
+    }
 }
diff --git a/pve-qemu-server-pci/src/types/mod.rs b/pve-qemu-server-pci/src/types/mod.rs
index 1605066..43b08c0 100644
--- a/pve-qemu-server-pci/src/types/mod.rs
+++ b/pve-qemu-server-pci/src/types/mod.rs
@@ -3,6 +3,9 @@ pub use bus::{Bus, PveBridge};
 
 mod device;
 pub use device::Device;
+// only needed to enumerate all device kinds in tests
+#[cfg(test)]
+pub(crate) use device::DeviceDiscriminants;
 
 mod pci_address;
 pub use pci_address::PciAddress;
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 12+ messages in thread

* [PATCH proxmox-perl-rs v2 5/9] pve: add bindings for `pve-qemu-server-pci` crate
  2026-10-15  8:10 [RFC proxmox-perl-rs/qemu-server/qemu-server-rs v2 0/9] pci-handling rewrite (part 1/2/3) Dominik Csapak
                   ` (3 preceding siblings ...)
  2026-10-15  8:10 ` [PATCH pve-qemu-server-rs v2 4/9] pci: layout: add grouped PCI and PCIe layouts Dominik Csapak
@ 2026-10-15  8:10 ` Dominik Csapak
  2026-10-15 11:47   ` Wolfgang Bumiller
  2026-10-15  8:10 ` [PATCH qemu-server v2 6/9] pci: use PVE::RS::PCI bindings for addresses and bus devices Dominik Csapak
                   ` (3 subsequent siblings)
  8 siblings, 1 reply; 12+ messages in thread
From: Dominik Csapak @ 2026-10-15  8:10 UTC (permalink / raw)
  To: pve-devel

These provide bindings for using the rust pci code in PVE's qemu-server
perl code. This exposes 'PVE::RS::PCI::HardwareConfig' which provides an
object that can be used to query the pci addresses of qemu vms.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 pve-rs/Cargo.toml                          |   1 +
 pve-rs/Makefile                            |   1 +
 pve-rs/src/bindings/mod.rs                 |   3 +
 pve-rs/src/bindings/pci/hardware_config.rs | 150 +++++++++++++++++++++
 pve-rs/src/bindings/pci/mod.rs             |   2 +
 5 files changed, 157 insertions(+)
 create mode 100644 pve-rs/src/bindings/pci/hardware_config.rs
 create mode 100644 pve-rs/src/bindings/pci/mod.rs

diff --git a/pve-rs/Cargo.toml b/pve-rs/Cargo.toml
index 5ae9082..0c4a47e 100644
--- a/pve-rs/Cargo.toml
+++ b/pve-rs/Cargo.toml
@@ -52,6 +52,7 @@ proxmox-tfa = { version = "6.0.3", features = ["api"] }
 proxmox-time = "2"
 proxmox-ve-config = { version = "0.10.4", features = [ "frr" ] }
 proxmox-wireguard = { version = "0.1.2" }
+pve-qemu-server-pci = "0.1"
 
 # [patch.crates-io]
 # pbs-api-types = { path = "../../proxmox/pbs-api-types" }
diff --git a/pve-rs/Makefile b/pve-rs/Makefile
index bb1cd2d..c239f2a 100644
--- a/pve-rs/Makefile
+++ b/pve-rs/Makefile
@@ -29,6 +29,7 @@ PERLMOD_PACKAGES := \
 	  PVE::RS::Firewall::SDN \
 	  PVE::RS::NVML \
 	  PVE::RS::OCI \
+	  PVE::RS::PCI::HardwareConfig \
 	  PVE::RS::OpenId \
 	  PVE::RS::ResourceScheduling::Static \
 	  PVE::RS::ResourceScheduling::Dynamic \
diff --git a/pve-rs/src/bindings/mod.rs b/pve-rs/src/bindings/mod.rs
index f922982..b26c602 100644
--- a/pve-rs/src/bindings/mod.rs
+++ b/pve-rs/src/bindings/mod.rs
@@ -6,6 +6,9 @@ pub use nvml::pve_rs_nvml;
 mod oci;
 pub use oci::pve_rs_oci;
 
+mod pci;
+pub use pci::pve_rs_pci_hardware_config;
+
 pub mod resource_scheduling;
 
 mod tfa;
diff --git a/pve-rs/src/bindings/pci/hardware_config.rs b/pve-rs/src/bindings/pci/hardware_config.rs
new file mode 100644
index 0000000..44e9d03
--- /dev/null
+++ b/pve-rs/src/bindings/pci/hardware_config.rs
@@ -0,0 +1,150 @@
+#[perlmod::package(name = "PVE::RS::PCI::HardwareConfig", lib = "pve_rs")]
+pub mod pve_rs_pci_hardware_config {
+    //! The `PVE::RS::PCI::HardwareConfig` package.
+    //!
+    //! Provides bindings for the `HardwareConfig` struct of the [`pve-qemu-server-pci`] crate.
+
+    use std::ops::Deref;
+    use std::rc::Rc;
+
+    use anyhow::Error;
+
+    use perlmod::Value;
+
+    use pve_qemu_server_pci::{HostpcieDevice, LayoutError, address};
+
+    perlmod::declare_magic!(Box<HardwareConfig>: &HardwareConfig as "PVE::RS::PCI::HardwareConfig");
+
+    /// Wrapper for [`pve_qemu_server_pci::HardwareConfig`].
+    pub struct HardwareConfig {
+        inner: Rc<pve_qemu_server_pci::HardwareConfig>,
+    }
+
+    impl Deref for HardwareConfig {
+        type Target = pve_qemu_server_pci::HardwareConfig;
+
+        fn deref(&self) -> &Self::Target {
+            &self.inner
+        }
+    }
+
+    #[export(raw_return)]
+    #[allow(clippy::too_many_arguments)]
+    /// Create an instance of the `HardwareConfig` struct.
+    ///
+    /// `hostpcie` lists the indices of the `hostpci` devices with `pcie` enabled.
+    pub fn new(
+        #[raw] class: Value,
+        arch: String,
+        machine_type: String,
+        scsihw: Option<String>,
+        max_scsihw: u8,
+        legacy_igd: bool,
+        ostype: Option<String>,
+        major: u16,
+        minor: u16,
+        pve: Option<u16>,
+        layout: &str,
+        hostpcie: Vec<u8>,
+    ) -> Result<Value, Error> {
+        let mut hw_config = pve_qemu_server_pci::HardwareConfig::new(
+            &arch,
+            machine_type,
+            scsihw,
+            max_scsihw,
+            legacy_igd,
+            ostype,
+            major,
+            minor,
+            pve,
+            layout,
+        );
+        hw_config.hostpcie_devices = hostpcie
+            .into_iter()
+            .map(|index| (index, HostpcieDevice::default()))
+            .collect();
+        Ok(
+            perlmod::instantiate_magic!(&class, MAGIC => Box::new(HardwareConfig { inner: Rc::new(hw_config) })),
+        )
+    }
+
+    #[export]
+    /// Returns the PCI or PCIe address of the chosen device on the given machine if the device
+    /// exists on the layout.
+    pub fn addr(
+        #[try_from_ref] this: &HardwareConfig,
+        id: &str,
+    ) -> Result<Option<String>, LayoutError> {
+        match address(this, id) {
+            Ok(addr) => Ok(Some(addr.to_qemu_addr())),
+            Err(LayoutError::NoAddressFound(_)) => Ok(None),
+            Err(other) => Err(other),
+        }
+    }
+
+    #[export]
+    /// Get the `-device` arguments of all bridges, root ports and chipset devices forming the
+    /// buses of the guest, which have to come before any device on them.
+    pub fn pci_bus_devices(
+        #[try_from_ref] machine: &HardwareConfig,
+    ) -> Result<Vec<String>, LayoutError> {
+        pve_qemu_server_pci::pci_bus_devices(machine)
+    }
+
+    #[export]
+    /// Whether the q35 chipset devices still have to be loaded from `pve-q35-4.0.cfg`.
+    pub fn uses_q35_config_file(#[try_from_ref] machine: &HardwareConfig) -> bool {
+        machine.uses_q35_config_file()
+    }
+
+    #[export]
+    /// Get the architecture of the machine
+    pub fn arch(#[try_from_ref] machine: &HardwareConfig) -> String {
+        machine.arch.to_string()
+    }
+
+    #[export]
+    /// Get the machines mainboard model
+    pub fn machine_type(#[try_from_ref] machine: &HardwareConfig) -> String {
+        machine.mainboard.to_string()
+    }
+
+    #[export]
+    /// Get the layout of the given machine.
+    pub fn layout(#[try_from_ref] machine: &HardwareConfig) -> String {
+        machine.layout.to_string()
+    }
+
+    #[export]
+    /// Checks if the given machine is at least the given version
+    pub fn version_at_least(
+        #[try_from_ref] machine: &HardwareConfig,
+        major: u16,
+        minor: u16,
+        pve: Option<u16>,
+    ) -> bool {
+        machine.version_at_least(major, minor, pve)
+    }
+
+    #[export]
+    /// returns the windows version (if any)
+    pub fn windows_version(#[try_from_ref] machine: &HardwareConfig) -> u8 {
+        machine.windows_version()
+    }
+
+    #[export]
+    /// Returns the bus the given device sits on, if it is a known device.
+    pub fn pci_bridge_for_device(
+        #[try_from_ref] machine: &HardwareConfig,
+        device: &str,
+    ) -> Option<String> {
+        let bus = pve_qemu_server_pci::pci_bridge_for_device(machine, device)?;
+        Some(bus.to_string())
+    }
+
+    #[export]
+    /// Returns the required '+pveX' version of the machine.
+    pub fn required_pve_version(#[try_from_ref] machine: &HardwareConfig) -> u16 {
+        machine.required_pve_version()
+    }
+}
diff --git a/pve-rs/src/bindings/pci/mod.rs b/pve-rs/src/bindings/pci/mod.rs
new file mode 100644
index 0000000..d1b00dd
--- /dev/null
+++ b/pve-rs/src/bindings/pci/mod.rs
@@ -0,0 +1,2 @@
+mod hardware_config;
+pub use hardware_config::pve_rs_pci_hardware_config;
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 12+ messages in thread

* [PATCH qemu-server v2 6/9] pci: use PVE::RS::PCI bindings for addresses and bus devices
  2026-10-15  8:10 [RFC proxmox-perl-rs/qemu-server/qemu-server-rs v2 0/9] pci-handling rewrite (part 1/2/3) Dominik Csapak
                   ` (4 preceding siblings ...)
  2026-10-15  8:10 ` [PATCH proxmox-perl-rs v2 5/9] pve: add bindings for `pve-qemu-server-pci` crate Dominik Csapak
@ 2026-10-15  8:10 ` Dominik Csapak
  2026-10-15  8:10 ` [PATCH qemu-server v2 7/9] tree-wide: use HardwareConfig for the machine version checks Dominik Csapak
                   ` (2 subsequent siblings)
  8 siblings, 0 replies; 12+ messages in thread
From: Dominik Csapak @ 2026-10-15  8:10 UTC (permalink / raw)
  To: pve-devel

Instead of the hardcoded address maps, use the pve-qemu-server-pci crate
via `PVE::RS::PCI::HardwareConfig` for the addresses of all devices, the
pci bridges and the root ports of passed through pcie devices.

The object contains the relevant parts of the guest config and the
machine version (for hotplug the one of the running instance), so we
construct it once for the commandline or a hotplug operation and pass it
to the functions that need an address, instead of the arch. The
functions that also check the machine version still get it separately.

The only functional change is that the root ports of the hostpcie
devices are now generated together with the bridges, so they move up on
the commandline. This also fixes hostpcie devices whose root port had no
function 0 in its slot (e.g. with only hostpci5 configured), which the
guest did not see. Adapt the hostpci tests accordingly (see [0] for the
initial bug). Since this is a configuration that is obviously wrong for
the user (so unlikely to occur in the real world), and the fact that pci
live migration is still marked experimental, it should be ok to not
guard that change behind a machine version.

The checks of run_pci_addr_checks.pl are now covered by the tests of the
rust crate, so remove it.

0: https://lore.proxmox.com/all/11ce85d1-eb5d-48d1-a4ba-046424613f29@pultar.org/

Fixes: c4e16381 (Add support for up to 16 PCI(e) devices)
Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 src/PVE/QemuServer.pm                         | 192 ++++++----
 src/PVE/QemuServer/DriveDevice.pm             |   5 +-
 src/PVE/QemuServer/PCI.pm                     | 339 ++----------------
 src/PVE/QemuServer/RNG.pm                     |   6 +-
 src/PVE/QemuServer/USB.pm                     |  26 +-
 src/test/Makefile                             |   5 +-
 src/test/TestsCommon/CommandLineMocks.pm      |   3 +
 .../q35-linux-hostpci-driver-keep.conf.cmd    |   4 +-
 src/test/cfg2cmd/q35-linux-hostpci-wrong.conf |  16 +
 .../cfg2cmd/q35-linux-hostpci-wrong.conf.cmd  |  41 +++
 src/test/cfg2cmd/q35-linux-hostpci.conf.cmd   |   4 +-
 src/test/cfg2cmd/q35-win10-hostpci.conf.cmd   |   2 +-
 src/test/run_pci_addr_checks.pl               | 141 --------
 13 files changed, 223 insertions(+), 561 deletions(-)
 create mode 100644 src/test/cfg2cmd/q35-linux-hostpci-wrong.conf
 create mode 100644 src/test/cfg2cmd/q35-linux-hostpci-wrong.conf.cmd
 delete mode 100755 src/test/run_pci_addr_checks.pl

diff --git a/src/PVE/QemuServer.pm b/src/PVE/QemuServer.pm
index 4a67d3a0..a393db78 100644
--- a/src/PVE/QemuServer.pm
+++ b/src/PVE/QemuServer.pm
@@ -86,7 +86,7 @@ use PVE::QemuServer::MetaInfo;
 use PVE::QemuServer::Monitor qw(mon_cmd qmp_cmd vm_qmp_peer);
 use PVE::QemuServer::Network;
 use PVE::QemuServer::OVMF;
-use PVE::QemuServer::PCI qw(print_pci_addr print_pcie_addr parse_hostpci get_pci_bridges);
+use PVE::QemuServer::PCI qw(parse_hostpci);
 use PVE::QemuServer::PCI::Mdev;
 use PVE::QemuServer::QemuImage;
 use PVE::QemuServer::QMPHelpers
@@ -1180,17 +1180,17 @@ sub assert_clipboard_config {
 }
 
 sub print_tabletdevice_full {
-    my ($conf, $arch) = @_;
+    my ($hw_config) = @_;
 
-    my $usbbus = PVE::QemuServer::USB::get_default_usb_bus($conf, $arch);
+    my $usbbus = PVE::QemuServer::USB::get_default_usb_bus($hw_config);
 
     return "usb-tablet,id=tablet,bus=$usbbus.0,port=1";
 }
 
 sub print_keyboarddevice_full {
-    my ($conf, $arch) = @_;
+    my ($hw_config) = @_;
 
-    return if $arch ne 'aarch64';
+    return if $hw_config->arch() ne 'aarch64';
 
     return "usb-kbd,id=keyboard,bus=ehci.0,port=2";
 }
@@ -1318,7 +1318,7 @@ sub print_netdevice_full {
         $net,
         $netid,
         $use_old_bios_files,
-        $arch,
+        $hw_config,
         $version_guard, # checks the machine version and records the required +pveX revision
         $host_mtu_migration, # force this value for host_mtu, 0 means force absence of param
     ) = @_;
@@ -1328,7 +1328,7 @@ sub print_netdevice_full {
         $device = 'virtio-net-pci';
     }
 
-    my $pciaddr = print_pci_addr("$netid", $arch);
+    my $pciaddr = $hw_config->addr("$netid");
     my $tmpstr = "$device,mac=$net->{macaddr},netdev=$netid$pciaddr,id=$netid";
     if ($net->{queues} && $net->{queues} > 1 && $net->{model} eq 'virtio') {
         # Consider we have N queues, the number of vectors needed is 2 * N + 2, i.e., one per in
@@ -1488,9 +1488,9 @@ my sub map_vga_model {
 }
 
 sub print_vga_device {
-    my ($conf, $vga, $arch, $machine_version, $id, $qxlnum) = @_;
+    my ($conf, $vga, $hw_config, $machine_version, $id, $qxlnum) = @_;
 
-    my $type = map_vga_model($vga->{type}, $arch);
+    my $type = map_vga_model($vga->{type}, $hw_config->arch());
     my $vgamem_mb = $vga->{memory};
 
     my $max_outputs = '';
@@ -1530,15 +1530,8 @@ sub print_vga_device {
         $edidoff = ",edid=off" if (!defined($conf->{bios}) || $conf->{bios} ne 'ovmf');
     }
 
-    my $q35 = PVE::QemuServer::Machine::machine_type_is_q35($conf);
     my $vgaid = "vga" . ($id // '');
-    my $pciaddr;
-    if ($q35 && $vgaid eq 'vga') {
-        # the first display uses pcie.0 bus on q35 machines
-        $pciaddr = print_pcie_addr($vgaid);
-    } else {
-        $pciaddr = print_pci_addr($vgaid, $arch);
-    }
+    my $pciaddr = $hw_config->addr($vgaid);
 
     if ($vga->{type} eq 'virtio-gl') {
         # VirGL renders on the host, so the guest arch is irrelevant for the library lookup
@@ -2810,10 +2803,11 @@ sub conf_has_audio {
 }
 
 sub audio_devs {
-    my ($audio, $audiopciaddr, $machine_version) = @_;
+    my ($audio, $hw_config, $machine_version) = @_;
 
     my $devs = [];
 
+    my $audiopciaddr = $hw_config->addr("audio0");
     my $id = $audio->{dev_id};
     my $audiodev = "";
     if (min_version($machine_version, 4, 2)) {
@@ -3267,6 +3261,11 @@ sub config_to_command {
         push $machineFlags->@*, $ovmf_machine_flags->@*;
     }
 
+    my $max_scsihw = PVE::QemuServer::DriveDevice::get_max_scsihw_index($conf);
+    my $hw_config = PVE::QemuServer::PCI::hardware_config(
+        $conf, $arch, $max_scsihw, $machine_version, $forcemachine,
+    );
+
     if ($q35) { # tell QEMU to load q35 config early
         # we use different pcie-port hardware for qemu >= 4.0 for passthrough
         if (min_version($machine_version, 4, 0)) {
@@ -3280,9 +3279,8 @@ sub config_to_command {
         push @$cmd, $fixups->@*;
     }
 
-    my $max_scsihw = PVE::QemuServer::DriveDevice::get_max_scsihw_index($conf);
-    if (my $bridges = get_pci_bridges($conf, $arch, $q35, $max_scsihw, $version_guard)) {
-        push @$devices, $bridges->@*;
+    if (my $bus_devices = $hw_config->pci_bus_devices()) {
+        push @$devices, $bus_devices->@*;
     }
 
     if ($conf->{vmgenid}) {
@@ -3290,7 +3288,8 @@ sub config_to_command {
     }
 
     # add usb controllers
-    my @usbcontrollers = PVE::QemuServer::USB::get_usb_controllers($conf, $arch, $machine_version);
+    my @usbcontrollers =
+        PVE::QemuServer::USB::get_usb_controllers($conf, $hw_config, $machine_version);
     push @$devices, @usbcontrollers if @usbcontrollers;
 
     my ($vga, $qxlnum) = get_vga_properties($conf, $arch, $machine_version, $winversion);
@@ -3304,8 +3303,8 @@ sub config_to_command {
     }
 
     if ($tablet) {
-        push @$devices, '-device', print_tabletdevice_full($conf, $arch) if $tablet;
-        my $kbd = print_keyboarddevice_full($conf, $arch);
+        push @$devices, '-device', print_tabletdevice_full($hw_config) if $tablet;
+        my $kbd = print_keyboarddevice_full($hw_config);
         push @$devices, '-device', $kbd if defined($kbd);
     }
 
@@ -3314,7 +3313,7 @@ sub config_to_command {
     # host pci device passthrough
     my ($kvm_off, $gpu_passthrough, $legacy_igd, $pci_devices) =
         PVE::QemuServer::PCI::print_hostpci_devices(
-            $vmid, $conf, $devices, $vga, $winversion, $arch, $bootorder, $dry_run,
+            $vmid, $conf, $devices, $vga, $winversion, $hw_config, $bootorder, $dry_run,
         );
 
     my @usbdevices = PVE::QemuServer::USB::get_usb_devices($conf, $bootorder, $machine_version);
@@ -3352,8 +3351,7 @@ sub config_to_command {
     }
 
     if (min_version($machine_version, 4, 0) && (my $audio = conf_has_audio($conf))) {
-        my $audiopciaddr = print_pci_addr("audio0", $arch);
-        my $audio_devs = audio_devs($audio, $audiopciaddr, $machine_version);
+        my $audio_devs = audio_devs($audio, $hw_config, $machine_version);
         push @$devices, @$audio_devs;
     }
 
@@ -3397,7 +3395,7 @@ sub config_to_command {
 
     if ($vga->{type} && $vga->{type} !~ m/^serial\d+$/ && $vga->{type} ne 'none') {
         push @$devices, '-device',
-            print_vga_device($conf, $vga, $arch, $machine_version, undef, $qxlnum);
+            print_vga_device($conf, $vga, $hw_config, $machine_version, undef, $qxlnum);
 
         push @$cmd, '-display', 'egl-headless,gl=core' if $vga->{type} eq 'virtio-gl'; # VIRGL
 
@@ -3457,7 +3455,7 @@ sub config_to_command {
         push @$devices, '-chardev', "socket,path=$qgasocket,server=on,wait=off,id=qga0";
 
         if (!$guest_agent->{type} || $guest_agent->{type} eq 'virtio') {
-            my $pciaddr = print_pci_addr("qga0", $arch);
+            my $pciaddr = $hw_config->addr("qga0");
             push @$devices, '-device', "virtio-serial,id=qga0$pciaddr";
             push @$devices, '-device', 'virtserialport,chardev=qga0,name=org.qemu.guest_agent.0';
         } elsif ($guest_agent->{type} eq 'isa') {
@@ -3468,7 +3466,7 @@ sub config_to_command {
     my $rng = $conf->{rng0} ? parse_rng($conf->{rng0}) : undef;
     if ($rng && $version_guard->(4, 1, 2)) {
         my $rng_object = print_rng_object_commandline('rng0', $rng);
-        my $rng_device = print_rng_device_commandline('rng0', $rng, $arch);
+        my $rng_device = print_rng_device_commandline('rng0', $rng, $hw_config);
         push @$devices, '-object', $rng_object;
         push @$devices, '-device', $rng_device;
     }
@@ -3482,9 +3480,8 @@ sub config_to_command {
         if ($qxlnum > 1) {
             if ($winversion) {
                 for (my $i = 1; $i < $qxlnum; $i++) {
-                    push @$devices, '-device', print_vga_device(
-                        $conf, $vga, $arch, $machine_version, $i, $qxlnum,
-                    );
+                    push @$devices, '-device',
+                        print_vga_device($conf, $vga, $hw_config, $machine_version, $i, $qxlnum);
                 }
             } else {
                 # assume other OS works like Linux
@@ -3498,7 +3495,7 @@ sub config_to_command {
             }
         }
 
-        my $pciaddr = print_pci_addr("spice", $arch);
+        my $pciaddr = $hw_config->addr("spice");
 
         push @$devices, '-device', "virtio-serial,id=spice$pciaddr";
         if ($vga->{'clipboard'} && $vga->{'clipboard'} eq 'vnc') {
@@ -3536,7 +3533,7 @@ sub config_to_command {
 
     # enable balloon by default, unless explicitly disabled
     if (!defined($conf->{balloon}) || $conf->{balloon}) {
-        my $pciaddr = print_pci_addr("balloon0", $arch);
+        my $pciaddr = $hw_config->addr("balloon0");
         my $ballooncmd = "virtio-balloon-pci,id=balloon0$pciaddr";
         $ballooncmd .= ",free-page-reporting=on" if min_version($machine_version, 6, 2);
         push @$devices, '-device', $ballooncmd;
@@ -3544,7 +3541,7 @@ sub config_to_command {
 
     if ($conf->{watchdog}) {
         my $wdopts = parse_watchdog($conf->{watchdog});
-        my $pciaddr = print_pci_addr("watchdog", $arch);
+        my $pciaddr = $hw_config->addr("watchdog");
         my $watchdog = $wdopts->{model} || 'i6300esb';
         push @$devices, '-device', "$watchdog$pciaddr";
         push @$devices, '-watchdog-action', $wdopts->{action} if $wdopts->{action};
@@ -3586,7 +3583,7 @@ sub config_to_command {
                     "scsi$drive->{index}: machine version 4.1~pve2 or higher is required to use more than 14 SCSI disks\n"
                     if $drive->{index} > 13 && !&$version_guard(4, 1, 2);
 
-                my $pciaddr = print_pci_addr("$controller_prefix$controller", $arch);
+                my $pciaddr = $hw_config->addr("$controller_prefix$controller");
                 my $scsihw_type =
                     $scsihw =~ m/^virtio-scsi-single/ ? "virtio-scsi-pci" : $scsihw;
 
@@ -3621,7 +3618,7 @@ sub config_to_command {
 
             if ($drive->{interface} eq 'sata') {
                 my $controller = int($drive->{index} / $PVE::QemuServer::Drive::MAX_SATA_DISKS);
-                my $pciaddr = print_pci_addr("ahci$controller", $arch);
+                my $pciaddr = $hw_config->addr("ahci$controller");
                 push @$devices, '-device', "ahci,id=ahci$controller,multifunction=on$pciaddr"
                     if !$ahcicontroller->{$controller};
                 $ahcicontroller->{$controller} = 1;
@@ -3666,7 +3663,7 @@ sub config_to_command {
             }
 
             push @$devices, '-device', print_drivedevice_full(
-                $storecfg, $conf, $vmid, $drive, $arch, $machine_type,
+                $storecfg, $conf, $vmid, $drive, $hw_config, $machine_type,
             );
         },
     );
@@ -3696,7 +3693,7 @@ sub config_to_command {
             $d,
             $netname,
             $use_old_bios_files,
-            $arch,
+            $hw_config,
             $version_guard,
             $nets_host_mtu->{$netname},
         );
@@ -3707,12 +3704,7 @@ sub config_to_command {
     if ($conf->{ivshmem}) {
         my $ivshmem = parse_property_string($ivshmem_fmt, $conf->{ivshmem});
 
-        my $bus;
-        if ($q35) {
-            $bus = print_pcie_addr("ivshmem");
-        } else {
-            $bus = print_pci_addr("ivshmem", $arch);
-        }
+        my $bus = $hw_config->addr("ivshmem");
 
         my $ivshmem_name = $ivshmem->{name} // $vmid;
         my $path = '/dev/shm/pve-shm-' . $ivshmem_name;
@@ -3859,18 +3851,18 @@ sub vm_devices_list {
 }
 
 sub vm_deviceplug {
-    my ($storecfg, $conf, $vmid, $deviceid, $device, $arch, $machine_type) = @_;
+    my ($storecfg, $conf, $vmid, $deviceid, $device, $hw_config, $machine_type) = @_;
 
     my $devices_list = vm_devices_list($vmid);
     return 1 if defined($devices_list->{$deviceid});
 
     # we can't hotplug bridges, so check if the necessary one exists
-    assert_pci_bridge_present($vmid, $deviceid);
+    assert_pci_bridge_present($vmid, $deviceid, $hw_config);
 
     if ($deviceid eq 'tablet') {
-        qemu_deviceadd($vmid, print_tabletdevice_full($conf, $arch));
+        qemu_deviceadd($vmid, print_tabletdevice_full($hw_config));
     } elsif ($deviceid eq 'keyboard') {
-        qemu_deviceadd($vmid, print_keyboarddevice_full($conf, $arch));
+        qemu_deviceadd($vmid, print_keyboarddevice_full($hw_config));
     } elsif ($deviceid =~ m/^usbredirdev(\d+)$/) {
         my $id = $1;
         qemu_spice_usbredir_chardev_add($vmid, "usbredirchardev$id");
@@ -3885,7 +3877,7 @@ sub vm_deviceplug {
 
         qemu_driveadd($storecfg, $vmid, $device);
         my $devicefull =
-            print_drivedevice_full($storecfg, $conf, $vmid, $device, $arch, $machine_type);
+            print_drivedevice_full($storecfg, $conf, $vmid, $device, $hw_config, $machine_type);
 
         qemu_deviceadd($vmid, $devicefull);
         eval { qemu_deviceaddverify($vmid, $deviceid); };
@@ -3898,7 +3890,7 @@ sub vm_deviceplug {
         }
     } elsif ($deviceid =~ m/^(virtioscsi|scsihw)(\d+)$/) {
         my $scsihw = defined($conf->{scsihw}) ? $conf->{scsihw} : "lsi";
-        my $pciaddr = print_pci_addr($deviceid, $arch);
+        my $pciaddr = $hw_config->addr($deviceid);
         my $scsihw_type = $scsihw eq 'virtio-scsi-single' ? "virtio-scsi-pci" : $scsihw;
 
         my $devicefull = "$scsihw_type,id=$deviceid$pciaddr";
@@ -3922,11 +3914,11 @@ sub vm_deviceplug {
             die $err;
         }
     } elsif ($deviceid =~ m/^(scsi)(\d+)$/) {
-        qemu_findorcreatescsihw($storecfg, $conf, $vmid, $device, $arch, $machine_type);
+        qemu_findorcreatescsihw($storecfg, $conf, $vmid, $device, $hw_config, $machine_type);
         qemu_driveadd($storecfg, $vmid, $device);
 
         my $devicefull =
-            print_drivedevice_full($storecfg, $conf, $vmid, $device, $arch, $machine_type);
+            print_drivedevice_full($storecfg, $conf, $vmid, $device, $hw_config, $machine_type);
         eval { qemu_deviceadd($vmid, $devicefull); };
         if (my $err = $@) {
             eval { qemu_drivedel($vmid, $deviceid); };
@@ -3934,7 +3926,7 @@ sub vm_deviceplug {
             die $err;
         }
     } elsif ($deviceid =~ m/^(net)(\d+)$/) {
-        return if !qemu_netdevadd($vmid, $conf, $arch, $device, $deviceid);
+        return if !qemu_netdevadd($vmid, $conf, $hw_config->arch(), $device, $deviceid);
 
         my $machine_type = PVE::QemuServer::Machine::qemu_machine_pxe($vmid, $conf);
         my $machine_version = PVE::QemuServer::Machine::extract_version($machine_type);
@@ -3947,7 +3939,13 @@ sub vm_deviceplug {
             return min_version($machine_version, $major, $minor, $pve);
         };
         my $netdevicefull = print_netdevice_full(
-            $vmid, $conf, $device, $deviceid, $use_old_bios_files, $arch, $version_guard,
+            $vmid,
+            $conf,
+            $device,
+            $deviceid,
+            $use_old_bios_files,
+            $hw_config,
+            $version_guard,
         );
         qemu_deviceadd($vmid, $netdevicefull);
         eval {
@@ -4129,7 +4127,7 @@ sub qemu_devicedelverify {
 }
 
 sub qemu_findorcreatescsihw {
-    my ($storecfg, $conf, $vmid, $device, $arch, $machine_type) = @_;
+    my ($storecfg, $conf, $vmid, $device, $hw_config, $machine_type) = @_;
 
     my ($maxdev, $controller, $controller_prefix) = scsihw_infos($conf->{scsihw}, $device->{index});
 
@@ -4137,7 +4135,7 @@ sub qemu_findorcreatescsihw {
     my $devices_list = vm_devices_list($vmid);
 
     if (!defined($devices_list->{$scsihwid})) {
-        vm_deviceplug($storecfg, $conf, $vmid, $scsihwid, $device, $arch, $machine_type);
+        vm_deviceplug($storecfg, $conf, $vmid, $scsihwid, $device, $hw_config, $machine_type);
     }
 
     return 1;
@@ -4176,12 +4174,11 @@ sub qemu_deletescsihw {
 }
 
 sub assert_pci_bridge_present {
-    my ($vmid, $device) = @_;
+    my ($vmid, $device, $hw_config) = @_;
 
-    my $bridgeid = PVE::QemuServer::PCI::get_pci_bridge_for_device($device);
-    return 1 if !defined($bridgeid) || $bridgeid < 1;
+    my $bridge = $hw_config->pci_bridge_for_device($device);
+    return 1 if !defined($bridge) || $bridge eq 'pci.0' || $bridge eq 'pcie.0';
 
-    my $bridge = "pci.$bridgeid";
     my $devices_list = vm_devices_list($vmid);
 
     if (!defined($devices_list->{$bridge})) {
@@ -4226,7 +4223,7 @@ sub qemu_netdevdel {
 }
 
 sub qemu_usb_hotplug {
-    my ($storecfg, $conf, $vmid, $deviceid, $device, $arch, $machine_type) = @_;
+    my ($storecfg, $conf, $vmid, $deviceid, $device, $hw_config, $machine_type) = @_;
 
     return if !$device;
 
@@ -4238,13 +4235,13 @@ sub qemu_usb_hotplug {
     my $devicelist = vm_devices_list($vmid);
 
     if (!$devicelist->{xhci}) {
-        my $pciaddr = print_pci_addr("xhci", $arch);
+        my $pciaddr = $hw_config->addr("xhci");
         qemu_deviceadd($vmid, PVE::QemuServer::USB::print_qemu_xhci_controller($pciaddr));
         $added_xhci = 1;
     }
 
     # add the new one
-    eval { vm_deviceplug($storecfg, $conf, $vmid, $deviceid, $device, $arch, $machine_type); };
+    eval { vm_deviceplug($storecfg, $conf, $vmid, $deviceid, $device, $hw_config, $machine_type); };
     if (my $err = $@) {
         if ($added_xhci) {
             eval { vm_deviceunplug($vmid, $conf, 'xhci'); };
@@ -4577,6 +4574,21 @@ sub vmconfig_hotplug_pending {
         return $_machine_type;
     };
 
+    my $_hw_config;
+    my $hw_config = sub {
+        return $_hw_config if defined($_hw_config);
+
+        my $max_scsihw = PVE::QemuServer::DriveDevice::get_max_scsihw_index($conf);
+        $_hw_config = PVE::QemuServer::PCI::hardware_config(
+            $conf,
+            $arch,
+            $max_scsihw,
+            PVE::QemuServer::Machine::extract_version($machine_type->()),
+        );
+
+        return $_hw_config;
+    };
+
     # commit values which do not have any impact on running VM first
     # Note: those option cannot raise errors, we we do not care about
     # $selection and always apply them.
@@ -4696,10 +4708,22 @@ sub vmconfig_hotplug_pending {
                 die "skip\n" if !$hotplug_features->{usb};
                 if ($defaults->{tablet}) {
                     vm_deviceplug(
-                        $storecfg, $conf, $vmid, 'tablet', undef, $arch, $machine_type->(),
+                        $storecfg,
+                        $conf,
+                        $vmid,
+                        'tablet',
+                        undef,
+                        $hw_config->(),
+                        $machine_type->(),
                     );
                     vm_deviceplug(
-                        $storecfg, $conf, $vmid, 'keyboard', undef, $arch, $machine_type->(),
+                        $storecfg,
+                        $conf,
+                        $vmid,
+                        'keyboard',
+                        undef,
+                        $hw_config->(),
+                        $machine_type->(),
                     ) if $arch eq 'aarch64';
                 } else {
                     vm_deviceunplug($vmid, $conf, 'tablet');
@@ -4770,10 +4794,22 @@ sub vmconfig_hotplug_pending {
                 die "skip\n" if !$hotplug_features->{usb};
                 if ($value == 1) {
                     vm_deviceplug(
-                        $storecfg, $conf, $vmid, 'tablet', undef, $arch, $machine_type->(),
+                        $storecfg,
+                        $conf,
+                        $vmid,
+                        'tablet',
+                        undef,
+                        $hw_config->(),
+                        $machine_type->(),
                     );
                     vm_deviceplug(
-                        $storecfg, $conf, $vmid, 'keyboard', undef, $arch, $machine_type->(),
+                        $storecfg,
+                        $conf,
+                        $vmid,
+                        'keyboard',
+                        undef,
+                        $hw_config->(),
+                        $machine_type->(),
                     ) if $arch eq 'aarch64';
                 } elsif ($value == 0) {
                     vm_deviceunplug($vmid, $conf, 'tablet');
@@ -4787,7 +4823,9 @@ sub vmconfig_hotplug_pending {
                 if ($d->{host} =~ m/^spice$/i) {
                     $id = "usbredirdev$index";
                 }
-                qemu_usb_hotplug($storecfg, $conf, $vmid, $id, $d, $arch, $machine_type->());
+                qemu_usb_hotplug(
+                    $storecfg, $conf, $vmid, $id, $d, $hw_config->(), $machine_type->(),
+                );
             } elsif ($opt eq 'vcpus') {
                 die "skip\n" if !$hotplug_features->{cpu};
                 qemu_cpu_hotplug($vmid, $conf, $value);
@@ -4813,7 +4851,7 @@ sub vmconfig_hotplug_pending {
                     $vmid,
                     $opt,
                     $value,
-                    $arch,
+                    $hw_config->(),
                     $machine_type->(),
                 );
             } elsif (is_valid_drivename($opt)) {
@@ -4832,7 +4870,7 @@ sub vmconfig_hotplug_pending {
                     $vmid,
                     $opt,
                     $value,
-                    $arch,
+                    $hw_config->(),
                     $machine_type->(),
                 );
             } elsif ($opt =~ m/^memory$/) { #dimms
@@ -4873,7 +4911,7 @@ sub vmconfig_hotplug_pending {
                 $vmid,
                 $opt,
                 $value,
-                $arch,
+                $hw_config->(),
                 $machine_type->(),
             );
         };
@@ -5068,7 +5106,7 @@ sub vmconfig_apply_pending {
 }
 
 sub vmconfig_update_net {
-    my ($storecfg, $conf, $hotplug, $vmid, $opt, $value, $arch, $machine_type) = @_;
+    my ($storecfg, $conf, $hotplug, $vmid, $opt, $value, $hw_config, $machine_type) = @_;
 
     my $newnet = PVE::QemuServer::Network::parse_net($value);
 
@@ -5160,7 +5198,7 @@ sub vmconfig_update_net {
         PVE::Network::SDN::Vnets::add_dhcp_mapping(
             $newnet->{bridge}, $newnet->{macaddr}, $vmid, $conf->{name},
         );
-        vm_deviceplug($storecfg, $conf, $vmid, $opt, $newnet, $arch, $machine_type);
+        vm_deviceplug($storecfg, $conf, $vmid, $opt, $newnet, $hw_config, $machine_type);
     } else {
         die "skip\n";
     }
@@ -5190,7 +5228,7 @@ sub vmconfig_update_agent {
 }
 
 sub vmconfig_update_disk {
-    my ($storecfg, $conf, $hotplug, $vmid, $opt, $value, $arch, $machine_type) = @_;
+    my ($storecfg, $conf, $hotplug, $vmid, $opt, $value, $hw_config, $machine_type) = @_;
 
     my $drive = parse_drive($opt, $value);
 
@@ -5296,7 +5334,7 @@ sub vmconfig_update_disk {
     die "skip\n" if !$hotplug || $opt =~ m/(ide|sata)(\d+)/;
     # hotplug new disks
     PVE::Storage::activate_volumes($storecfg, [$drive->{file}]) if $drive->{file} !~ m|^/dev/.+|;
-    vm_deviceplug($storecfg, $conf, $vmid, $opt, $drive, $arch, $machine_type);
+    vm_deviceplug($storecfg, $conf, $vmid, $opt, $drive, $hw_config, $machine_type);
 }
 
 sub vmconfig_update_cloudinit_drive {
diff --git a/src/PVE/QemuServer/DriveDevice.pm b/src/PVE/QemuServer/DriveDevice.pm
index 621fcc2b..28588433 100644
--- a/src/PVE/QemuServer/DriveDevice.pm
+++ b/src/PVE/QemuServer/DriveDevice.pm
@@ -8,7 +8,6 @@ use URI::Escape;
 use PVE::QemuServer::Drive qw (drive_is_cdrom);
 use PVE::QemuServer::Helpers qw(kvm_user_version min_version);
 use PVE::QemuServer::Machine;
-use PVE::QemuServer::PCI qw(print_pci_addr);
 
 use base qw(Exporter);
 
@@ -69,7 +68,7 @@ sub get_max_scsihw_index {
 }
 
 sub print_drivedevice_full {
-    my ($storecfg, $conf, $vmid, $drive, $arch, $machine_type) = @_;
+    my ($storecfg, $conf, $vmid, $drive, $hw_config, $machine_type) = @_;
 
     my $device = '';
     my $maxdev = 0;
@@ -82,7 +81,7 @@ sub print_drivedevice_full {
 
     my $drive_id = PVE::QemuServer::Drive::get_drive_id($drive);
     if ($drive->{interface} eq 'virtio') {
-        my $pciaddr = print_pci_addr("$drive_id", $arch);
+        my $pciaddr = $hw_config->addr("$drive_id");
         $device = 'virtio-blk-pci';
         # for the switch to -blockdev, there is no blockdev for 'none'
         if (!$use_blockdev || $drive->{file} ne 'none') {
diff --git a/src/PVE/QemuServer/PCI.pm b/src/PVE/QemuServer/PCI.pm
index d7dc6121..6b4eb179 100644
--- a/src/PVE/QemuServer/PCI.pm
+++ b/src/PVE/QemuServer/PCI.pm
@@ -7,6 +7,7 @@ use IO::File;
 
 use PVE::JSONSchema;
 use PVE::Mapping::PCI;
+use PVE::RS::PCI::HardwareConfig;
 use PVE::SysFSTools;
 use PVE::Tools;
 
@@ -17,10 +18,7 @@ use PVE::QemuServer::PCI::Mdev;
 use base 'Exporter';
 
 our @EXPORT_OK = qw(
-    print_pci_addr
-    print_pcie_addr
     parse_hostpci
-    get_pci_bridges
 );
 
 our $MAX_HOSTPCI_DEVICES = 16;
@@ -152,266 +150,6 @@ EODESCR
 };
 PVE::JSONSchema::register_standard_option("pve-qm-hostpci", $hostpcidesc);
 
-my $pci_addr_map;
-
-sub get_pci_addr_map {
-    $pci_addr_map = {
-        piix3 => { bus => 0, addr => 1, conflict_ok => qw(ehci) },
-        ehci => { bus => 0, addr => 1, conflict_ok => qw(piix3) }, # instead of piix3 on arm
-        vga => { bus => 0, addr => 2, conflict_ok => qw(legacy-igd) },
-        'legacy-igd' => { bus => 0, addr => 2, conflict_ok => qw(vga) }, # legacy-igd requires vga=none
-        balloon0 => { bus => 0, addr => 3 },
-        watchdog => { bus => 0, addr => 4 },
-        scsihw0 => { bus => 0, addr => 5, conflict_ok => qw(pci.3) },
-        'pci.3' => { bus => 0, addr => 5, conflict_ok => qw(scsihw0) }, # also used for virtio-scsi-single bridge
-        scsihw1 => { bus => 0, addr => 6 },
-        ahci0 => { bus => 0, addr => 7 },
-        qga0 => { bus => 0, addr => 8 },
-        spice => { bus => 0, addr => 9 },
-        virtio0 => { bus => 0, addr => 10 },
-        virtio1 => { bus => 0, addr => 11 },
-        virtio2 => { bus => 0, addr => 12 },
-        virtio3 => { bus => 0, addr => 13 },
-        virtio4 => { bus => 0, addr => 14 },
-        virtio5 => { bus => 0, addr => 15 },
-        hostpci0 => { bus => 0, addr => 16 },
-        hostpci1 => { bus => 0, addr => 17 },
-        net0 => { bus => 0, addr => 18 },
-        net1 => { bus => 0, addr => 19 },
-        net2 => { bus => 0, addr => 20 },
-        net3 => { bus => 0, addr => 21 },
-        net4 => { bus => 0, addr => 22 },
-        net5 => { bus => 0, addr => 23 },
-        vga1 => { bus => 0, addr => 24 },
-        vga2 => { bus => 0, addr => 25 },
-        vga3 => { bus => 0, addr => 26 },
-        hostpci2 => { bus => 0, addr => 27 },
-        hostpci3 => { bus => 0, addr => 28 },
-        #addr29 : usb-host (pve-usb.cfg)
-        'pci.1' => { bus => 0, addr => 30 },
-        'pci.2' => { bus => 0, addr => 31 },
-        'net6' => { bus => 1, addr => 1 },
-        'net7' => { bus => 1, addr => 2 },
-        'net8' => { bus => 1, addr => 3 },
-        'net9' => { bus => 1, addr => 4 },
-        'net10' => { bus => 1, addr => 5 },
-        'net11' => { bus => 1, addr => 6 },
-        'net12' => { bus => 1, addr => 7 },
-        'net13' => { bus => 1, addr => 8 },
-        'net14' => { bus => 1, addr => 9 },
-        'net15' => { bus => 1, addr => 10 },
-        'net16' => { bus => 1, addr => 11 },
-        'net17' => { bus => 1, addr => 12 },
-        'net18' => { bus => 1, addr => 13 },
-        'net19' => { bus => 1, addr => 14 },
-        'net20' => { bus => 1, addr => 15 },
-        'net21' => { bus => 1, addr => 16 },
-        'net22' => { bus => 1, addr => 17 },
-        'net23' => { bus => 1, addr => 18 },
-        'net24' => { bus => 1, addr => 19 },
-        'net25' => { bus => 1, addr => 20 },
-        'net26' => { bus => 1, addr => 21 },
-        'net27' => { bus => 1, addr => 22 },
-        'net28' => { bus => 1, addr => 23 },
-        'net29' => { bus => 1, addr => 24 },
-        'net30' => { bus => 1, addr => 25 },
-        'net31' => { bus => 1, addr => 26 },
-        'xhci' => { bus => 1, addr => 27 },
-        'pci.4' => { bus => 1, addr => 28 },
-        'rng0' => { bus => 1, addr => 29 },
-        'pci.2-igd' => { bus => 1, addr => 30 }, # replaces pci.2 in case a legacy IGD device is passed through
-        'virtio6' => { bus => 2, addr => 1 },
-        'virtio7' => { bus => 2, addr => 2 },
-        'virtio8' => { bus => 2, addr => 3 },
-        'virtio9' => { bus => 2, addr => 4 },
-        'virtio10' => { bus => 2, addr => 5 },
-        'virtio11' => { bus => 2, addr => 6 },
-        'virtio12' => { bus => 2, addr => 7 },
-        'virtio13' => { bus => 2, addr => 8 },
-        'virtio14' => { bus => 2, addr => 9 },
-        'virtio15' => { bus => 2, addr => 10 },
-        'ivshmem' => { bus => 2, addr => 11 },
-        'audio0' => { bus => 2, addr => 12 },
-        hostpci4 => { bus => 2, addr => 13 },
-        hostpci5 => { bus => 2, addr => 14 },
-        hostpci6 => { bus => 2, addr => 15 },
-        hostpci7 => { bus => 2, addr => 16 },
-        hostpci8 => { bus => 2, addr => 17 },
-        hostpci9 => { bus => 2, addr => 18 },
-        hostpci10 => { bus => 2, addr => 19 },
-        hostpci11 => { bus => 2, addr => 20 },
-        hostpci12 => { bus => 2, addr => 21 },
-        hostpci13 => { bus => 2, addr => 22 },
-        hostpci14 => { bus => 2, addr => 23 },
-        hostpci15 => { bus => 2, addr => 24 },
-        'virtioscsi0' => { bus => 3, addr => 1 },
-        'virtioscsi1' => { bus => 3, addr => 2 },
-        'virtioscsi2' => { bus => 3, addr => 3 },
-        'virtioscsi3' => { bus => 3, addr => 4 },
-        'virtioscsi4' => { bus => 3, addr => 5 },
-        'virtioscsi5' => { bus => 3, addr => 6 },
-        'virtioscsi6' => { bus => 3, addr => 7 },
-        'virtioscsi7' => { bus => 3, addr => 8 },
-        'virtioscsi8' => { bus => 3, addr => 9 },
-        'virtioscsi9' => { bus => 3, addr => 10 },
-        'virtioscsi10' => { bus => 3, addr => 11 },
-        'virtioscsi11' => { bus => 3, addr => 12 },
-        'virtioscsi12' => { bus => 3, addr => 13 },
-        'virtioscsi13' => { bus => 3, addr => 14 },
-        'virtioscsi14' => { bus => 3, addr => 15 },
-        'virtioscsi15' => { bus => 3, addr => 16 },
-        'virtioscsi16' => { bus => 3, addr => 17 },
-        'virtioscsi17' => { bus => 3, addr => 18 },
-        'virtioscsi18' => { bus => 3, addr => 19 },
-        'virtioscsi19' => { bus => 3, addr => 20 },
-        'virtioscsi20' => { bus => 3, addr => 21 },
-        'virtioscsi21' => { bus => 3, addr => 22 },
-        'virtioscsi22' => { bus => 3, addr => 23 },
-        'virtioscsi23' => { bus => 3, addr => 24 },
-        'virtioscsi24' => { bus => 3, addr => 25 },
-        'virtioscsi25' => { bus => 3, addr => 26 },
-        'virtioscsi26' => { bus => 3, addr => 27 },
-        'virtioscsi27' => { bus => 3, addr => 28 },
-        'virtioscsi28' => { bus => 3, addr => 29 },
-        'virtioscsi29' => { bus => 3, addr => 30 },
-        'virtioscsi30' => { bus => 3, addr => 31 },
-        'scsihw2' => { bus => 4, addr => 1 },
-        'scsihw3' => { bus => 4, addr => 2 },
-        'scsihw4' => { bus => 4, addr => 3 },
-        }
-        if !defined($pci_addr_map);
-    return $pci_addr_map;
-}
-
-my $get_addr_mapping_from_id = sub {
-    my ($map, $id) = @_;
-
-    my $d = $map->{$id};
-    return if !defined($d) || !defined($d->{bus}) || !defined($d->{addr});
-
-    return { bus => $d->{bus}, addr => sprintf("0x%x", $d->{addr}) };
-};
-
-sub get_pci_bridge_for_device {
-    my ($id) = @_;
-
-    my $map = get_pci_addr_map();
-    if (my $d = $get_addr_mapping_from_id->($map, $id)) {
-        return $d->{bus};
-    }
-
-    return;
-}
-
-sub print_pci_addr {
-    my ($id, $arch) = @_;
-
-    die "aarch64 cannot use IDE devices\n" if $arch eq 'aarch64' && $id =~ /^ide/;
-
-    my $res = '';
-
-    my $map = get_pci_addr_map();
-    if (my $d = $get_addr_mapping_from_id->($map, $id)) {
-        # Using same bus slots on all HW, so we need to check special cases here. For aarch64, the
-        # virt machine has an initial pcie.0. The other pci bridges that get added are called pci.N.
-        my $busname = $arch eq 'aarch64' && $d->{bus} eq 0 ? 'pcie' : 'pci';
-
-        $res = ",bus=$busname.$d->{bus},addr=$d->{addr}";
-    }
-
-    return $res;
-}
-
-my $pcie_addr_map;
-
-sub get_pcie_addr_map {
-    $pcie_addr_map = {
-        vga => { bus => 'pcie.0', addr => 1 },
-        hostpci0 => { bus => "ich9-pcie-port-1", addr => 0 },
-        hostpci1 => { bus => "ich9-pcie-port-2", addr => 0 },
-        hostpci2 => { bus => "ich9-pcie-port-3", addr => 0 },
-        hostpci3 => { bus => "ich9-pcie-port-4", addr => 0 },
-        hostpci4 => { bus => "ich9-pcie-port-5", addr => 0 },
-        hostpci5 => { bus => "ich9-pcie-port-6", addr => 0 },
-        hostpci6 => { bus => "ich9-pcie-port-7", addr => 0 },
-        hostpci7 => { bus => "ich9-pcie-port-8", addr => 0 },
-        hostpci8 => { bus => "ich9-pcie-port-9", addr => 0 },
-        hostpci9 => { bus => "ich9-pcie-port-10", addr => 0 },
-        hostpci10 => { bus => "ich9-pcie-port-11", addr => 0 },
-        hostpci11 => { bus => "ich9-pcie-port-12", addr => 0 },
-        hostpci12 => { bus => "ich9-pcie-port-13", addr => 0 },
-        hostpci13 => { bus => "ich9-pcie-port-14", addr => 0 },
-        hostpci14 => { bus => "ich9-pcie-port-15", addr => 0 },
-        hostpci15 => { bus => "ich9-pcie-port-16", addr => 0 },
-        # win7 is picky about pcie assignments
-        hostpci0bus0 => { bus => "pcie.0", addr => 16 },
-        hostpci1bus0 => { bus => "pcie.0", addr => 17 },
-        hostpci2bus0 => { bus => "pcie.0", addr => 18 },
-        hostpci3bus0 => { bus => "pcie.0", addr => 19 },
-        ivshmem => { bus => 'pcie.0', addr => 20 },
-        hostpci4bus0 => { bus => "pcie.0", addr => 9 },
-        hostpci5bus0 => { bus => "pcie.0", addr => 10 },
-        hostpci6bus0 => { bus => "pcie.0", addr => 11 },
-        hostpci7bus0 => { bus => "pcie.0", addr => 12 },
-        hostpci8bus0 => { bus => "pcie.0", addr => 13 },
-        hostpci9bus0 => { bus => "pcie.0", addr => 14 },
-        hostpci10bus0 => { bus => "pcie.0", addr => 15 },
-        hostpci11bus0 => { bus => "pcie.0", addr => 21 },
-        hostpci12bus0 => { bus => "pcie.0", addr => 22 },
-        hostpci13bus0 => { bus => "pcie.0", addr => 23 },
-        hostpci14bus0 => { bus => "pcie.0", addr => 24 },
-        hostpci15bus0 => { bus => "pcie.0", addr => 25 },
-        }
-        if !defined($pcie_addr_map);
-
-    return $pcie_addr_map;
-}
-
-sub print_pcie_addr {
-    my ($id) = @_;
-
-    my $res = '';
-
-    my $map = get_pcie_addr_map($id);
-    if (my $d = $get_addr_mapping_from_id->($map, $id)) {
-        $res = ",bus=$d->{bus},addr=$d->{addr}";
-    }
-
-    return $res;
-}
-
-# Generates the device strings for additional pcie root ports. The first 4 pcie
-# root ports are defined in the pve-q35*.cfg files.
-my sub print_pcie_root_port {
-    my ($i) = @_;
-    my $res = '';
-
-    my $root_port_addresses = {
-        4 => "10.0",
-        5 => "10.1",
-        6 => "10.2",
-        7 => "10.3",
-        8 => "10.4",
-        9 => "10.5",
-        10 => "10.6",
-        11 => "10.7",
-        12 => "11.0",
-        13 => "11.1",
-        14 => "11.2",
-        15 => "11.3",
-    };
-
-    if (defined($root_port_addresses->{$i})) {
-        my $id = $i + 1;
-        $res = "pcie-root-port,id=ich9-pcie-port-${id}";
-        $res .= ",addr=$root_port_addresses->{$i}";
-        $res .= ",x-speed=16,x-width=32,multifunction=on,bus=pcie.0";
-        $res .= ",port=${id},chassis=${id}";
-    }
-
-    return $res;
-}
-
 # returns the parsed pci config but parses the 'host' part into
 # a list if lists into the 'id' property like this:
 #
@@ -627,7 +365,7 @@ sub choose_hostpci_devices {
 }
 
 sub print_hostpci_devices {
-    my ($vmid, $conf, $devices, $vga, $winversion, $arch, $bootorder, $dry_run) = @_;
+    my ($vmid, $conf, $devices, $vga, $winversion, $hw_config, $bootorder, $dry_run) = @_;
 
     my $kvm_off = 0;
     my $gpu_passthrough = 0;
@@ -644,21 +382,11 @@ sub print_hostpci_devices {
         $legacy_igd = 1 if $d->{'legacy-igd'};
 
         if (my $pcie = $d->{pcie}) {
-            # win7 wants to have the pcie devices directly on the pcie bus
-            # instead of in the root port
-            if ($winversion == 7) {
-                $pciaddr = print_pcie_addr("${id}bus0");
-            } else {
-                # add more root ports if needed, 4 are present by default
-                # by pve-q35 cfgs, rest added here on demand.
-                if ($i > 3) {
-                    push @$devices, '-device', print_pcie_root_port($i);
-                }
-                $pciaddr = print_pcie_addr($id);
-            }
+            # the root ports come from pci_bus_devices
+            $pciaddr = $hw_config->addr("hostpcie$i");
         } else {
             my $pci_name = $d->{'legacy-igd'} ? 'legacy-igd' : $id;
-            $pciaddr = print_pci_addr($pci_name, $arch);
+            $pciaddr = $hw_config->addr($pci_name);
         }
 
         my $num_devices = scalar($d->{ids}->@*);
@@ -881,50 +609,35 @@ sub reserve_pci_usage {
     die $@ if $@;
 }
 
-# Returns a list of bridge devices which are necessary for the remaining
-# devices.
-sub get_pci_bridges {
-    my ($conf, $arch, $q35, $max_scsihw, $version_guard) = @_;
+sub hardware_config {
+    my ($conf, $arch, $max_scsihw, $machine_version, $forcemachine) = @_;
 
-    my $bridges = {
-        # 0 => 1, always present
-        1 => 1,
-        2 => 1,
-    };
-
-    $bridges->{3} = 1 if ($conf->{scsihw} // '') =~ m/^virtio-scsi-single/;
-
-    # some scsi controllers can only have 7 scsi disks per controller,
-    # so scsi14 and upwards need scsihw2,3,4 which live on bridge 4
-    $bridges->{4} = 1 if $max_scsihw > 1 || $version_guard->(11, 1);
-
-    # use cheap legacy igd check instead of a full parse_hostpci
     my $legacy_igd = 0;
+    my $hostpcie = [];
     for (my $i = 0; $i < $MAX_HOSTPCI_DEVICES; $i++) {
         next if !defined($conf->{"hostpci$i"});
         my $res = PVE::JSONSchema::parse_property_string($hostpci_fmt, $conf->{"hostpci$i"});
         next if !defined($res);
-        if ($res->{'legacy-igd'}) {
-            $legacy_igd = 1;
-            last;
-        }
+        $legacy_igd = 1 if $res->{'legacy-igd'};
+        push @$hostpcie, $i if $res->{pcie};
     }
 
-    my $devices = [];
-    for my $k (sort { $a <=> $b } keys %$bridges) {
-        next if $q35 && $k < 4; # q35.cfg already includes bridges up to 3
-
-        my $k_name = $k;
-        if ($k == 2 && $legacy_igd) {
-            $k_name = "$k-igd";
-        }
-        my $pciaddr = print_pci_addr("pci.$k_name", $arch);
-        my $devstr = "pci-bridge,id=pci.$k,chassis_nr=$k$pciaddr";
-
-        push @$devices, '-device', $devstr;
-    }
-
-    return $devices;
+    my $machine_type = PVE::QemuServer::Machine::get_vm_machine($conf, $forcemachine);
+    my ($major, $minor, $patch, $pve) = PVE::QemuServer::Helpers::version_parts($machine_version);
+
+    return PVE::RS::PCI::HardwareConfig->new(
+        $arch // '',
+        $machine_type,
+        $conf->{scsihw},
+        $max_scsihw + 0,
+        $legacy_igd,
+        $conf->{ostype},
+        $major,
+        $minor,
+        $pve,
+        'legacy',
+        $hostpcie,
+    );
 }
 
 1;
diff --git a/src/PVE/QemuServer/RNG.pm b/src/PVE/QemuServer/RNG.pm
index f854dd50..eabc4540 100644
--- a/src/PVE/QemuServer/RNG.pm
+++ b/src/PVE/QemuServer/RNG.pm
@@ -6,8 +6,6 @@ use warnings;
 use PVE::JSONSchema;
 use PVE::Tools qw(file_read_firstline);
 
-use PVE::QemuServer::PCI qw(print_pci_addr);
-
 use base 'Exporter';
 
 our @EXPORT_OK = qw(
@@ -87,7 +85,7 @@ sub check_rng_source {
 }
 
 sub print_rng_device_commandline {
-    my ($id, $rng, $arch) = @_;
+    my ($id, $rng, $hw_config) = @_;
 
     die "no rng device specified\n" if !$rng;
 
@@ -98,7 +96,7 @@ sub print_rng_device_commandline {
         $limiter_str = ",max-bytes=$max_bytes,period=$period";
     }
 
-    my $rng_addr = print_pci_addr($id, $arch);
+    my $rng_addr = $hw_config->addr($id);
 
     return "virtio-rng-pci,rng=$id$limiter_str$rng_addr";
 }
diff --git a/src/PVE/QemuServer/USB.pm b/src/PVE/QemuServer/USB.pm
index 0ab43614..f5e2e31f 100644
--- a/src/PVE/QemuServer/USB.pm
+++ b/src/PVE/QemuServer/USB.pm
@@ -2,7 +2,6 @@ package PVE::QemuServer::USB;
 
 use strict;
 use warnings;
-use PVE::QemuServer::PCI qw(print_pci_addr);
 use PVE::QemuServer::Machine;
 use PVE::QemuServer::Helpers qw(min_version windows_version);
 use PVE::JSONSchema;
@@ -120,16 +119,14 @@ my sub assert_usb_index_is_useable {
 }
 
 sub get_default_usb_bus {
-    my ($conf, $arch) = @_;
-
-    my $q35 = PVE::QemuServer::Machine::machine_type_is_q35($conf);
+    my ($hw_config) = @_;
 
     # we use uhci for old VMs because tablet driver was buggy in older qemu
-    return $q35 || $arch eq 'aarch64' ? 'ehci' : 'uhci';
+    return $hw_config->machine_type() eq 'i440fx' ? 'uhci' : 'ehci';
 }
 
 sub get_usb_controllers {
-    my ($conf, $arch, $machine_version) = @_;
+    my ($conf, $hw_config, $machine_version) = @_;
 
     my $devices = [];
     my $pciaddr = "";
@@ -140,14 +137,15 @@ sub get_usb_controllers {
         min_version($machine_version, 7, 1)
         && defined($ostype)
         && ($ostype eq 'l26' || windows_version($ostype) > 7);
-    my $is_q35 = PVE::QemuServer::Machine::machine_type_is_q35($conf);
 
-    if ($arch eq 'aarch64') {
-        $pciaddr = print_pci_addr('ehci', $arch);
-        push @$devices, '-device', "usb-ehci,id=ehci$pciaddr";
-    } elsif (!$is_q35) {
-        $pciaddr = print_pci_addr("piix3", $arch);
+    my $machine_type = $hw_config->machine_type();
+
+    if ($machine_type eq 'i440fx') {
+        $pciaddr = $hw_config->addr("piix3");
         push @$devices, '-device', "piix3-usb-uhci,id=uhci$pciaddr.0x2";
+    } elsif ($machine_type eq 'virt') {
+        $pciaddr = $hw_config->addr("ehci");
+        push @$devices, '-device', "usb-ehci,id=ehci$pciaddr";
     }
 
     my ($use_usb2, $use_usb3) = 0;
@@ -161,12 +159,12 @@ sub get_usb_controllers {
         $use_usb2 = 1 if !$d->{usb3};
     }
 
-    if (!$use_qemu_xhci && !$is_q35 && $use_usb2 && $arch ne 'aarch64') {
+    if (!$use_qemu_xhci && $use_usb2 && $machine_type eq 'i440fx') {
         # include usb device config if still on x86 before-xhci machines and if USB 3 is not used
         push @$devices, '-readconfig', '/usr/share/qemu-server/pve-usb.cfg';
     }
 
-    $pciaddr = print_pci_addr("xhci", $arch);
+    $pciaddr = $hw_config->addr("xhci");
     if ($use_qemu_xhci && $any_usb) {
         push @$devices, '-device', print_qemu_xhci_controller($pciaddr);
     } elsif ($use_usb3) {
diff --git a/src/test/Makefile b/src/test/Makefile
index 702c9c03..07fadb99 100644
--- a/src/test/Makefile
+++ b/src/test/Makefile
@@ -1,6 +1,6 @@
 all: test
 
-test: test_snapshot test_cfg_to_cmd test_cfg_to_cmd_aarch64 test_hotplug test_hotplug_aarch64 test_pci_addr_conflicts test_pci_reservation test_qemu_img_convert test_migration test_restore_config test_parse_config
+test: test_snapshot test_cfg_to_cmd test_cfg_to_cmd_aarch64 test_hotplug test_hotplug_aarch64 test_pci_reservation test_qemu_img_convert test_migration test_restore_config test_parse_config
 
 test_snapshot: run_snapshot_tests.pl
 	./run_snapshot_tests.pl
@@ -21,9 +21,6 @@ test_hotplug_aarch64: run_hotplug_tests.pl TestsCommon/*.pm hotplug/aarch64/*.co
 test_qemu_img_convert: run_qemu_img_convert_tests.pl
 	perl -I../ ./run_qemu_img_convert_tests.pl
 
-test_pci_addr_conflicts: run_pci_addr_checks.pl
-	./run_pci_addr_checks.pl
-
 test_pci_reservation: run_pci_reservation_tests.pl
 	./run_pci_reservation_tests.pl
 
diff --git a/src/test/TestsCommon/CommandLineMocks.pm b/src/test/TestsCommon/CommandLineMocks.pm
index 50540e8f..a4b91136 100644
--- a/src/test/TestsCommon/CommandLineMocks.pm
+++ b/src/test/TestsCommon/CommandLineMocks.pm
@@ -347,6 +347,9 @@ $qemu_server_helpers->mock(
     get_host_phys_address_bits => sub {
         return 46;
     },
+    kvm_user_version => sub {
+        return get_test_qemu_version();
+    },
 );
 
 our $qemu_server_memory;
diff --git a/src/test/cfg2cmd/q35-linux-hostpci-driver-keep.conf.cmd b/src/test/cfg2cmd/q35-linux-hostpci-driver-keep.conf.cmd
index 9adc2e9e..d64264ec 100644
--- a/src/test/cfg2cmd/q35-linux-hostpci-driver-keep.conf.cmd
+++ b/src/test/cfg2cmd/q35-linux-hostpci-driver-keep.conf.cmd
@@ -27,15 +27,15 @@
   -global 'ICH9-LPC.disable_s4=1' \
   -readconfig /usr/share/qemu-server/pve-q35-4.0.cfg \
   -device 'pci-bridge,id=pci.4,chassis_nr=4,bus=pci.1,addr=0x1c' \
+  -device 'pcie-root-port,id=ich9-pcie-port-5,addr=10.0,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=5,chassis=5' \
+  -device 'pcie-root-port,id=ich9-pcie-port-8,addr=10.3,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=8,chassis=8' \
   -device 'vmgenid,guid=54d1c06c-8f5b-440f-b5b2-6eab1380e13d' \
   -device 'usb-tablet,id=tablet,bus=ehci.0,port=1' \
   -device 'vfio-pci,host=0000:00:ff.1,id=hostpci0,bus=pci.0,addr=0x10' \
   -device 'vfio-pci,host=0000:d0:13.0,id=hostpci1,bus=ich9-pcie-port-2,addr=0x0' \
   -device 'vfio-pci,host=0000:00:f4.0,id=hostpci2,bus=pci.0,addr=0x1b' \
   -device 'vfio-pci,host=0000:d0:15.1,id=hostpci3,bus=ich9-pcie-port-4,addr=0x0' \
-  -device 'pcie-root-port,id=ich9-pcie-port-5,addr=10.0,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=5,chassis=5' \
   -device 'vfio-pci,host=0000:d0:17.0,id=hostpci4,bus=ich9-pcie-port-5,addr=0x0,rombar=0' \
-  -device 'pcie-root-port,id=ich9-pcie-port-8,addr=10.3,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=8,chassis=8' \
   -device 'vfio-pci,host=0000:d0:15.2,id=hostpci7,bus=ich9-pcie-port-8,addr=0x0' \
   -device 'VGA,id=vga,bus=pcie.0,addr=0x1' \
   -device 'virtio-balloon-pci,id=balloon0,bus=pci.0,addr=0x3,free-page-reporting=on' \
diff --git a/src/test/cfg2cmd/q35-linux-hostpci-wrong.conf b/src/test/cfg2cmd/q35-linux-hostpci-wrong.conf
new file mode 100644
index 00000000..85458507
--- /dev/null
+++ b/src/test/cfg2cmd/q35-linux-hostpci-wrong.conf
@@ -0,0 +1,16 @@
+# TEST: Config with q35, NUMA, hostpci passthrough, EFI & Linux, only configure some hostpci devices that don't live on 0 function root port
+bios: ovmf
+bootdisk: scsi0
+cores: 1
+efidisk0: local:100/vm-100-disk-1.qcow2,size=128K
+hostpci7: d0:15.2,pcie=1
+hostpci15: 01:00.4,pcie=1
+machine: pc-q35-11.0
+memory: 512
+net0: virtio=2E:01:68:F9:9C:87,bridge=vmbr0
+numa: 1
+ostype: l26
+scsihw: virtio-scsi-pci
+smbios1: uuid=3dd750ce-d910-44d0-9493-525c0be4e687
+sockets: 2
+vmgenid: 54d1c06c-8f5b-440f-b5b2-6eab1380e13d
diff --git a/src/test/cfg2cmd/q35-linux-hostpci-wrong.conf.cmd b/src/test/cfg2cmd/q35-linux-hostpci-wrong.conf.cmd
new file mode 100644
index 00000000..7a2ef382
--- /dev/null
+++ b/src/test/cfg2cmd/q35-linux-hostpci-wrong.conf.cmd
@@ -0,0 +1,41 @@
+/usr/bin/kvm \
+  -id 8006 \
+  -name vm8006 \
+  -no-shutdown \
+  -chardev 'socket,id=qmp,path=/var/run/qemu-server/8006.qmp,server=on,wait=off' \
+  -object 'monitor-qmp,chardev=qmp,id=monitor-qmp' \
+  -chardev 'socket,id=qmp-event,path=/var/run/qmeventd.sock,reconnect-ms=5000' \
+  -object 'monitor-qmp,chardev=qmp-event,id=monitor-qmp-event' \
+  -pidfile /var/run/qemu-server/8006.pid \
+  -daemonize \
+  -smbios 'type=1,uuid=3dd750ce-d910-44d0-9493-525c0be4e687' \
+  -object '{"id":"throttle-drive-efidisk0","limits":{},"qom-type":"throttle-group"}' \
+  -blockdev '{"driver":"raw","file":{"driver":"file","filename":"/usr/share/pve-edk2-firmware//OVMF_CODE.fd"},"node-name":"pflash0","read-only":true}' \
+  -blockdev '{"detect-zeroes":"on","discard":"ignore","driver":"throttle","file":{"cache":{"direct":false,"no-flush":false},"detect-zeroes":"on","discard":"ignore","driver":"qcow2","file":{"aio":"io_uring","cache":{"direct":false,"no-flush":false},"detect-zeroes":"on","discard":"ignore","driver":"file","filename":"/var/lib/vz/images/100/vm-100-disk-1.qcow2","node-name":"e70e3017c5a79fdee5a04aa92ac1e9c","read-only":false},"node-name":"f70e3017c5a79fdee5a04aa92ac1e9c","read-only":false},"node-name":"drive-efidisk0","read-only":false,"throttle-group":"throttle-drive-efidisk0"}' \
+  -smp '2,sockets=2,cores=1,maxcpus=2' \
+  -nodefaults \
+  -boot 'menu=on,strict=on,reboot-timeout=1000,splash=/usr/share/qemu-server/bootsplash.jpg' \
+  -vnc 'unix:/var/run/qemu-server/8006.vnc,password=on' \
+  -cpu kvm64,enforce,+kvm_pv_eoi,+kvm_pv_unhalt,+lahf_lm,+sep \
+  -m 512 \
+  -object 'memory-backend-ram,id=ram-node0,size=256M' \
+  -numa 'node,nodeid=0,cpus=0,memdev=ram-node0' \
+  -object 'memory-backend-ram,id=ram-node1,size=256M' \
+  -numa 'node,nodeid=1,cpus=1,memdev=ram-node1' \
+  -global 'ICH9-LPC.disable_s3=1' \
+  -global 'ICH9-LPC.disable_s4=1' \
+  -readconfig /usr/share/qemu-server/pve-q35-4.0.cfg \
+  -device 'pcie-root-port,id=ich9-pcie-port-5,addr=10.0,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=5,chassis=5' \
+  -device 'pcie-root-port,id=ich9-pcie-port-8,addr=10.3,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=8,chassis=8' \
+  -device 'pcie-root-port,id=ich9-pcie-port-13,addr=11.0,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=13,chassis=13' \
+  -device 'pcie-root-port,id=ich9-pcie-port-16,addr=11.3,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=16,chassis=16' \
+  -device 'vmgenid,guid=54d1c06c-8f5b-440f-b5b2-6eab1380e13d' \
+  -device 'usb-tablet,id=tablet,bus=ehci.0,port=1' \
+  -device 'vfio-pci,host=0000:d0:15.2,id=hostpci7,bus=ich9-pcie-port-8,addr=0x0' \
+  -device 'vfio-pci,host=0000:01:00.4,id=hostpci15,bus=ich9-pcie-port-16,addr=0x0' \
+  -device 'VGA,id=vga,bus=pcie.0,addr=0x1' \
+  -device 'virtio-balloon-pci,id=balloon0,bus=pci.0,addr=0x3,free-page-reporting=on' \
+  -iscsi 'initiator-name=iqn.1993-08.org.debian:01:aabbccddeeff' \
+  -netdev 'type=tap,id=net0,ifname=tap8006i0,script=/usr/libexec/qemu-server/pve-bridge,downscript=/usr/libexec/qemu-server/pve-bridgedown,vhost=on' \
+  -device 'virtio-net-pci,mac=2E:01:68:F9:9C:87,netdev=net0,bus=pci.0,addr=0x12,id=net0,rx_queue_size=1024,tx_queue_size=256,bootindex=300,host_mtu=1500' \
+  -machine 'pflash0=pflash0,pflash1=drive-efidisk0,hpet=off,type=pc-q35-11.0+pve0'
diff --git a/src/test/cfg2cmd/q35-linux-hostpci.conf.cmd b/src/test/cfg2cmd/q35-linux-hostpci.conf.cmd
index 9adc2e9e..d64264ec 100644
--- a/src/test/cfg2cmd/q35-linux-hostpci.conf.cmd
+++ b/src/test/cfg2cmd/q35-linux-hostpci.conf.cmd
@@ -27,15 +27,15 @@
   -global 'ICH9-LPC.disable_s4=1' \
   -readconfig /usr/share/qemu-server/pve-q35-4.0.cfg \
   -device 'pci-bridge,id=pci.4,chassis_nr=4,bus=pci.1,addr=0x1c' \
+  -device 'pcie-root-port,id=ich9-pcie-port-5,addr=10.0,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=5,chassis=5' \
+  -device 'pcie-root-port,id=ich9-pcie-port-8,addr=10.3,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=8,chassis=8' \
   -device 'vmgenid,guid=54d1c06c-8f5b-440f-b5b2-6eab1380e13d' \
   -device 'usb-tablet,id=tablet,bus=ehci.0,port=1' \
   -device 'vfio-pci,host=0000:00:ff.1,id=hostpci0,bus=pci.0,addr=0x10' \
   -device 'vfio-pci,host=0000:d0:13.0,id=hostpci1,bus=ich9-pcie-port-2,addr=0x0' \
   -device 'vfio-pci,host=0000:00:f4.0,id=hostpci2,bus=pci.0,addr=0x1b' \
   -device 'vfio-pci,host=0000:d0:15.1,id=hostpci3,bus=ich9-pcie-port-4,addr=0x0' \
-  -device 'pcie-root-port,id=ich9-pcie-port-5,addr=10.0,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=5,chassis=5' \
   -device 'vfio-pci,host=0000:d0:17.0,id=hostpci4,bus=ich9-pcie-port-5,addr=0x0,rombar=0' \
-  -device 'pcie-root-port,id=ich9-pcie-port-8,addr=10.3,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=8,chassis=8' \
   -device 'vfio-pci,host=0000:d0:15.2,id=hostpci7,bus=ich9-pcie-port-8,addr=0x0' \
   -device 'VGA,id=vga,bus=pcie.0,addr=0x1' \
   -device 'virtio-balloon-pci,id=balloon0,bus=pci.0,addr=0x3,free-page-reporting=on' \
diff --git a/src/test/cfg2cmd/q35-win10-hostpci.conf.cmd b/src/test/cfg2cmd/q35-win10-hostpci.conf.cmd
index bc7d1120..2506c7fd 100644
--- a/src/test/cfg2cmd/q35-win10-hostpci.conf.cmd
+++ b/src/test/cfg2cmd/q35-win10-hostpci.conf.cmd
@@ -23,11 +23,11 @@
   -object 'memory-backend-ram,id=ram-node1,size=256M' \
   -numa 'node,nodeid=1,cpus=1,memdev=ram-node1' \
   -readconfig /usr/share/qemu-server/pve-q35-4.0.cfg \
+  -device 'pcie-root-port,id=ich9-pcie-port-5,addr=10.0,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=5,chassis=5' \
   -device 'vmgenid,guid=54d1c06c-8f5b-440f-b5b2-6eab1380e13d' \
   -device 'usb-tablet,id=tablet,bus=ehci.0,port=1' \
   -device 'vfio-pci,host=0000:f0:42.0,id=hostpci0,bus=pci.0,addr=0x10' \
   -device 'vfio-pci,host=0000:f0:43.0,id=hostpci1,bus=ich9-pcie-port-2,addr=0x0' \
-  -device 'pcie-root-port,id=ich9-pcie-port-5,addr=10.0,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=5,chassis=5' \
   -device 'vfio-pci,host=0000:00:43.1,id=hostpci4,bus=ich9-pcie-port-5,addr=0x0' \
   -device 'VGA,id=vga,bus=pcie.0,addr=0x1' \
   -device 'virtio-balloon-pci,id=balloon0,bus=pci.0,addr=0x3' \
diff --git a/src/test/run_pci_addr_checks.pl b/src/test/run_pci_addr_checks.pl
deleted file mode 100755
index 866c43eb..00000000
--- a/src/test/run_pci_addr_checks.pl
+++ /dev/null
@@ -1,141 +0,0 @@
-#!/usr/bin/perl
-
-use strict;
-use warnings;
-use experimental 'smartmatch';
-
-use lib qw(..);
-
-use Test::More;
-
-use PVE::Tools qw(file_get_contents);
-use PVE::QemuServer::PCI;
-
-my $qemu_cfg_base_path = "../usr";
-
-# not our format but that what QEMU gets passed with '-readconfig'
-sub slurp_qemu_config {
-    my ($fn) = @_;
-
-    my $raw = file_get_contents($fn);
-
-    my $lineno = 0;
-    my $cfg = {};
-    my $group;
-    my $skip_to_next_group;
-    while ($raw =~ /^\h*(.*?)\h*$/gm) {
-        my $line = $1;
-        $lineno++;
-        next if !$line || $line =~ /^#/;
-
-        # tried to follow qemu's qemu_config_parse function
-        if ($line =~ /\[(\S{1,63}) "([^"\]]{1,63})"\]/) {
-            $group = $2;
-            $skip_to_next_group = 0;
-            if ($1 ne 'device') {
-                $group = undef;
-                $skip_to_next_group = 1;
-            }
-        } elsif ($line =~ /\[([^\]]{1,63})\]/) {
-            $group = undef;
-            $skip_to_next_group = 1;
-        } elsif ($group) {
-            if ($line =~ /(\S{1,63}) = "([^\"]{1,1023})"/) {
-                my ($k, $v) = ($1, $2);
-                $cfg->{$group}->{$k} = $v;
-            } else {
-                print "ignoring $fn:$lineno: $line\n";
-            }
-        } else {
-            warn "ignore $fn:$lineno, currently no group\n" if !$skip_to_next_group;
-        }
-    }
-
-    return $cfg;
-}
-
-sub extract_qemu_config_addrs {
-    my ($qemu_cfg) = @_;
-
-    my $addr_map = {};
-    for my $k (keys %$qemu_cfg) {
-        my $v = $qemu_cfg->{$k};
-        next if !$v || !defined($v->{bus}) || !defined($v->{addr});
-
-        my $bus = $v->{bus};
-        $bus =~ s/pci\.//;
-
-        $addr_map->{$k} = { bus => $bus, addr => $v->{addr} };
-    }
-
-    return $addr_map;
-}
-
-print "testing PCI(e) address conflicts\n";
-
-# exec tests
-
-#FIXME: make cross PCI <-> PCIe check sense at all??
-my $addr_map = {};
-my ($fail, $ignored) = (0, 0);
-
-sub check_conflict {
-    my ($id, $what, $ignore_if_same_key) = @_;
-
-    my ($bus, $addr) = $what->@{qw(bus addr)};
-    my $full_addr = "$bus:$addr";
-
-    if (defined(my $conflict = $addr_map->{$full_addr})) {
-        if (my @ignores = $what->{conflict_ok}) {
-            if ($conflict ~~ @ignores) {
-                note("OK: ignore conflict for '$full_addr' between '$id' and '$conflict'");
-                $ignored++;
-                return;
-            }
-        }
-        # this allows to read multiple pve-*.cfg qemu configs, and check them
-        # normally their OK if they conflict is on the same key. Else TODO??
-        return if $ignore_if_same_key && $id eq $conflict;
-
-        note("ERR: conflict for '$full_addr' between '$id' and '$conflict'");
-        $fail++;
-    } else {
-        $addr_map->{$full_addr} = $id;
-    }
-}
-
-my $pci_map = PVE::QemuServer::PCI::get_pci_addr_map();
-while (my ($id, $what) = each %$pci_map) {
-    check_conflict($id, $what);
-}
-
-my $pcie_map = PVE::QemuServer::PCI::get_pcie_addr_map();
-while (my ($id, $what) = each %$pcie_map) {
-    check_conflict($id, $what);
-}
-
-my $pve_qm_cfg = slurp_qemu_config("$qemu_cfg_base_path/pve-q35.cfg");
-my $pve_qm_cfg_map = extract_qemu_config_addrs($pve_qm_cfg);
-while (my ($id, $what) = each %$pve_qm_cfg_map) {
-    check_conflict($id, $what);
-}
-
-# FIXME: restart with clean conflict $addr_map with only get_pci*_addr_map ones?
-my $pve_qm4_cfg = slurp_qemu_config("$qemu_cfg_base_path/pve-q35-4.0.cfg");
-my $pve_qm4_cfg_map = extract_qemu_config_addrs($pve_qm4_cfg);
-while (my ($id, $what) = each %$pve_qm4_cfg_map) {
-    check_conflict($id, $what, 1);
-}
-my $pve_qm_usb_cfg = slurp_qemu_config("$qemu_cfg_base_path/pve-usb.cfg");
-my $pve_qm_usb_cfg_map = extract_qemu_config_addrs($pve_qm_usb_cfg);
-while (my ($id, $what) = each %$pve_qm_usb_cfg_map) {
-    check_conflict($id, $what, 1);
-}
-
-if ($fail) {
-    fail("PCI(e) address conflict check, ignored: $ignored, conflicts: $fail");
-} else {
-    pass("PCI(e) address conflict check, ignored: $ignored");
-}
-
-done_testing();
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 12+ messages in thread

* [PATCH qemu-server v2 7/9] tree-wide: use HardwareConfig for the machine version checks
  2026-10-15  8:10 [RFC proxmox-perl-rs/qemu-server/qemu-server-rs v2 0/9] pci-handling rewrite (part 1/2/3) Dominik Csapak
                   ` (5 preceding siblings ...)
  2026-10-15  8:10 ` [PATCH qemu-server v2 6/9] pci: use PVE::RS::PCI bindings for addresses and bus devices Dominik Csapak
@ 2026-10-15  8:10 ` Dominik Csapak
  2026-10-15  8:10 ` [PATCH qemu-server v2 8/9] cfg2cmd: q35: generate chipset devices with machine version 11.1+pve1 Dominik Csapak
  2026-10-15  8:10 ` [PATCH qemu-server v2 9/9] machine: add 'pci-layout' option Dominik Csapak
  8 siblings, 0 replies; 12+ messages in thread
From: Dominik Csapak @ 2026-10-15  8:10 UTC (permalink / raw)
  To: pve-devel

The HardwareConfig object already contains the machine version, and
its version_at_least records the required +pveX revision like the
version guard does. So use it for the machine version checks in the
functions that get it anyway, instead of also passing the machine
version, the machine type or the version guard there.

The revisions recorded by the object are merged into the version
guard at the end of config_to_command. For hotplug, the object is
built from the running machine, so the stub version guard for NICs is
not needed anymore. Drive hotplug now also uses that version instead
of falling back to the installed QEMU version, which does not
necessarily match the running one anyway.

The windows version in config_to_command is now also taken from the
object.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 src/PVE/API2/Qemu.pm              |   9 +-
 src/PVE/QemuServer.pm             | 155 ++++++++++--------------------
 src/PVE/QemuServer/Drive.pm       |   7 +-
 src/PVE/QemuServer/DriveDevice.pm |   9 +-
 src/PVE/QemuServer/USB.pm         |  10 +-
 5 files changed, 67 insertions(+), 123 deletions(-)

diff --git a/src/PVE/API2/Qemu.pm b/src/PVE/API2/Qemu.pm
index cfbc1f51..c5fb78f3 100644
--- a/src/PVE/API2/Qemu.pm
+++ b/src/PVE/API2/Qemu.pm
@@ -1071,8 +1071,13 @@ sub assert_scsi_feature_compatibility {
         $machine_type,
         PVE::QemuServer::Helpers::kvm_user_version(),
     );
-    my $drivetype =
-        PVE::QemuServer::Drive::get_scsi_device_type($drive, $storecfg, $machine_version);
+    my $hw_config = PVE::QemuServer::PCI::hardware_config(
+        $conf,
+        PVE::QemuServer::Helpers::get_vm_arch($conf),
+        PVE::QemuServer::DriveDevice::get_max_scsihw_index($conf),
+        $machine_version,
+    );
+    my $drivetype = PVE::QemuServer::Drive::get_scsi_device_type($drive, $storecfg, $hw_config);
 
     if ($drivetype ne 'hd' && $drivetype ne 'cd') {
         if ($drive->{product}) {
diff --git a/src/PVE/QemuServer.pm b/src/PVE/QemuServer.pm
index a393db78..d6a5ed6b 100644
--- a/src/PVE/QemuServer.pm
+++ b/src/PVE/QemuServer.pm
@@ -1319,7 +1319,6 @@ sub print_netdevice_full {
         $netid,
         $use_old_bios_files,
         $hw_config,
-        $version_guard, # checks the machine version and records the required +pveX revision
         $host_mtu_migration, # force this value for host_mtu, 0 means force absence of param
     ) = @_;
 
@@ -1335,12 +1334,12 @@ sub print_netdevice_full {
         # and out of each queue plus one config interrupt and control vector queue
         my $vectors = $net->{queues} * 2 + 2;
         $tmpstr .= ",vectors=$vectors,mq=on";
-        if ($version_guard->(7, 1)) {
+        if ($hw_config->version_at_least(7, 1)) {
             $tmpstr .= ",packed=on";
         }
     }
 
-    if ($version_guard->(7, 1) && $net->{model} eq 'virtio') {
+    if ($hw_config->version_at_least(7, 1) && $net->{model} eq 'virtio') {
         $tmpstr .= ",rx_queue_size=1024,tx_queue_size=256";
     }
 
@@ -1370,7 +1369,7 @@ sub print_netdevice_full {
                 . " that the bridge is configured as desired.\n";
         }
 
-        if ($version_guard->(10, 0, 1) || $host_mtu_migration) {
+        if ($hw_config->version_at_least(10, 0, 1) || $host_mtu_migration) {
             # Always add host_mtu for migration compatibility, because the presence of host_mtu
             # means that the virtual hardware is generated differently (at least for i440fx)
             $tmpstr .= ",host_mtu=$mtu";
@@ -1382,7 +1381,7 @@ sub print_netdevice_full {
         if ($migration_skip_host_mtu) {
             # When the machine version is less than 10.0+pve1 and the MTU is 1500, not having the
             # host_mtu parameter is fully expected. Only log when not expected to avoid confusion.
-            if ($version_guard->(10, 0, 1) || $mtu != 1500) {
+            if ($hw_config->version_at_least(10, 0, 1) || $mtu != 1500) {
                 log_warn(
                     "$msg_prefix, not used on the source side according to migration parameters");
             }
@@ -1415,10 +1414,10 @@ sub print_netdevice_full {
     # include the fix "virtio_net: do not allow tunnel csum offload for non GSO packets" with a
     # prominent notice in the upgrade guide.
     if ($net->{model} eq 'virtio') {
-        if ($version_guard->(11, 0, 1)) {
+        if ($hw_config->version_at_least(11, 0, 1)) {
             # Turn off if not explicitly enabled starting with 11.0+pve1
             $tmpstr .= ",host_tunnel=off" if !$net->{'host-tunnel'};
-        } elsif ($version_guard->(10, 2)) {
+        } elsif ($hw_config->version_at_least(10, 2)) {
             # The feature was introduced in QEMU 10.2. Turn off if explicitly disabled.
             $tmpstr .= ",host_tunnel=off"
                 if defined($net->{'host-tunnel'}) && !$net->{'host-tunnel'};
@@ -1488,7 +1487,7 @@ my sub map_vga_model {
 }
 
 sub print_vga_device {
-    my ($conf, $vga, $hw_config, $machine_version, $id, $qxlnum) = @_;
+    my ($conf, $vga, $hw_config, $id, $qxlnum) = @_;
 
     my $type = map_vga_model($vga->{type}, $hw_config->arch());
     my $vgamem_mb = $vga->{memory};
@@ -1499,7 +1498,7 @@ sub print_vga_device {
 
         if (!$conf->{ostype} || $conf->{ostype} =~ m/^(?:l\d\d)|(?:other)$/) {
             # set max outputs so linux can have up to 4 qxl displays with one device
-            if (min_version($machine_version, 4, 1)) {
+            if ($hw_config->version_at_least(4, 1)) {
                 $max_outputs = ",max_outputs=4";
             }
         }
@@ -1526,7 +1525,7 @@ sub print_vga_device {
     }
 
     my $edidoff = "";
-    if ($type eq 'VGA' && windows_version($conf->{ostype})) {
+    if ($type eq 'VGA' && $hw_config->windows_version()) {
         $edidoff = ",edid=off" if (!defined($conf->{bios}) || $conf->{bios} ne 'ovmf');
     }
 
@@ -2803,14 +2802,14 @@ sub conf_has_audio {
 }
 
 sub audio_devs {
-    my ($audio, $hw_config, $machine_version) = @_;
+    my ($audio, $hw_config) = @_;
 
     my $devs = [];
 
     my $audiopciaddr = $hw_config->addr("audio0");
     my $id = $audio->{dev_id};
     my $audiodev = "";
-    if (min_version($machine_version, 4, 2)) {
+    if ($hw_config->version_at_least(4, 2)) {
         $audiodev = ",audiodev=$audio->{backend_id}";
     }
 
@@ -3123,7 +3122,6 @@ sub config_to_command {
     my ($machineFlags, $rtcFlags) = ([], []);
     my $devices = [];
     my $ostype = $conf->{ostype};
-    my $winversion = windows_version($ostype);
     my $kvm = $conf->{kvm};
     my $nodename = nodename();
 
@@ -3283,13 +3281,14 @@ sub config_to_command {
         push @$devices, $bus_devices->@*;
     }
 
+    my $winversion = $hw_config->windows_version();
+
     if ($conf->{vmgenid}) {
         push @$devices, '-device', 'vmgenid,guid=' . $conf->{vmgenid};
     }
 
     # add usb controllers
-    my @usbcontrollers =
-        PVE::QemuServer::USB::get_usb_controllers($conf, $hw_config, $machine_version);
+    my @usbcontrollers = PVE::QemuServer::USB::get_usb_controllers($conf, $hw_config);
     push @$devices, @usbcontrollers if @usbcontrollers;
 
     my ($vga, $qxlnum) = get_vga_properties($conf, $arch, $machine_version, $winversion);
@@ -3316,7 +3315,7 @@ sub config_to_command {
             $vmid, $conf, $devices, $vga, $winversion, $hw_config, $bootorder, $dry_run,
         );
 
-    my @usbdevices = PVE::QemuServer::USB::get_usb_devices($conf, $bootorder, $machine_version);
+    my @usbdevices = PVE::QemuServer::USB::get_usb_devices($conf, $bootorder, $hw_config);
     push @$devices, @usbdevices if @usbdevices;
 
     # serial devices
@@ -3350,8 +3349,8 @@ sub config_to_command {
         }
     }
 
-    if (min_version($machine_version, 4, 0) && (my $audio = conf_has_audio($conf))) {
-        my $audio_devs = audio_devs($audio, $hw_config, $machine_version);
+    if ($hw_config->version_at_least(4, 0) && (my $audio = conf_has_audio($conf))) {
+        my $audio_devs = audio_devs($audio, $hw_config);
         push @$devices, @$audio_devs;
     }
 
@@ -3394,8 +3393,7 @@ sub config_to_command {
     push @$cmd, '-no-reboot' if defined($conf->{reboot}) && $conf->{reboot} == 0;
 
     if ($vga->{type} && $vga->{type} !~ m/^serial\d+$/ && $vga->{type} ne 'none') {
-        push @$devices, '-device',
-            print_vga_device($conf, $vga, $hw_config, $machine_version, undef, $qxlnum);
+        push @$devices, '-device', print_vga_device($conf, $vga, $hw_config, undef, $qxlnum);
 
         push @$cmd, '-display', 'egl-headless,gl=core' if $vga->{type} eq 'virtio-gl'; # VIRGL
 
@@ -3481,7 +3479,7 @@ sub config_to_command {
             if ($winversion) {
                 for (my $i = 1; $i < $qxlnum; $i++) {
                     push @$devices, '-device',
-                        print_vga_device($conf, $vga, $hw_config, $machine_version, $i, $qxlnum);
+                        print_vga_device($conf, $vga, $hw_config, $i, $qxlnum);
                 }
             } else {
                 # assume other OS works like Linux
@@ -3662,9 +3660,8 @@ sub config_to_command {
                 push @$devices, '-drive', $drive_cmd;
             }
 
-            push @$devices, '-device', print_drivedevice_full(
-                $storecfg, $conf, $vmid, $drive, $hw_config, $machine_type,
-            );
+            push @$devices, '-device',
+                print_drivedevice_full($storecfg, $conf, $vmid, $drive, $hw_config);
         },
     );
 
@@ -3694,7 +3691,6 @@ sub config_to_command {
             $netname,
             $use_old_bios_files,
             $hw_config,
-            $version_guard,
             $nets_host_mtu->{$netname},
         );
 
@@ -3723,6 +3719,9 @@ sub config_to_command {
 
     push @$machineFlags, 'smm=off' if should_disable_smm($conf, $vga, $machine_type);
 
+    # take pve versions from hw_config into account
+    $version_guard->($machine_major, $machine_minor, $hw_config->required_pve_version());
+
     my $machine_type_min = $machine_type;
     $machine_type_min =~ s/\+pve\d+$//;
     $machine_type_min .= "+pve$required_pve_version";
@@ -3851,7 +3850,7 @@ sub vm_devices_list {
 }
 
 sub vm_deviceplug {
-    my ($storecfg, $conf, $vmid, $deviceid, $device, $hw_config, $machine_type) = @_;
+    my ($storecfg, $conf, $vmid, $deviceid, $device, $hw_config) = @_;
 
     my $devices_list = vm_devices_list($vmid);
     return 1 if defined($devices_list->{$deviceid});
@@ -3876,8 +3875,7 @@ sub vm_deviceplug {
         qemu_iothread_add($vmid, $deviceid, $device);
 
         qemu_driveadd($storecfg, $vmid, $device);
-        my $devicefull =
-            print_drivedevice_full($storecfg, $conf, $vmid, $device, $hw_config, $machine_type);
+        my $devicefull = print_drivedevice_full($storecfg, $conf, $vmid, $device, $hw_config);
 
         qemu_deviceadd($vmid, $devicefull);
         eval { qemu_deviceaddverify($vmid, $deviceid); };
@@ -3914,11 +3912,12 @@ sub vm_deviceplug {
             die $err;
         }
     } elsif ($deviceid =~ m/^(scsi)(\d+)$/) {
-        qemu_findorcreatescsihw($storecfg, $conf, $vmid, $device, $hw_config, $machine_type);
+        qemu_findorcreatescsihw(
+            $storecfg, $conf, $vmid, $device, $hw_config,
+        );
         qemu_driveadd($storecfg, $vmid, $device);
 
-        my $devicefull =
-            print_drivedevice_full($storecfg, $conf, $vmid, $device, $hw_config, $machine_type);
+        my $devicefull = print_drivedevice_full($storecfg, $conf, $vmid, $device, $hw_config);
         eval { qemu_deviceadd($vmid, $devicefull); };
         if (my $err = $@) {
             eval { qemu_drivedel($vmid, $deviceid); };
@@ -3929,23 +3928,11 @@ sub vm_deviceplug {
         return if !qemu_netdevadd($vmid, $conf, $hw_config->arch(), $device, $deviceid);
 
         my $machine_type = PVE::QemuServer::Machine::qemu_machine_pxe($vmid, $conf);
-        my $machine_version = PVE::QemuServer::Machine::extract_version($machine_type);
         my $use_old_bios_files = undef;
         ($use_old_bios_files, $machine_type) = qemu_use_old_bios_files($machine_type);
 
-        # the machine is already running, so there is no required version to record
-        my $version_guard = sub {
-            my ($major, $minor, $pve) = @_;
-            return min_version($machine_version, $major, $minor, $pve);
-        };
         my $netdevicefull = print_netdevice_full(
-            $vmid,
-            $conf,
-            $device,
-            $deviceid,
-            $use_old_bios_files,
-            $hw_config,
-            $version_guard,
+            $vmid, $conf, $device, $deviceid, $use_old_bios_files, $hw_config,
         );
         qemu_deviceadd($vmid, $netdevicefull);
         eval {
@@ -4127,7 +4114,7 @@ sub qemu_devicedelverify {
 }
 
 sub qemu_findorcreatescsihw {
-    my ($storecfg, $conf, $vmid, $device, $hw_config, $machine_type) = @_;
+    my ($storecfg, $conf, $vmid, $device, $hw_config) = @_;
 
     my ($maxdev, $controller, $controller_prefix) = scsihw_infos($conf->{scsihw}, $device->{index});
 
@@ -4135,7 +4122,7 @@ sub qemu_findorcreatescsihw {
     my $devices_list = vm_devices_list($vmid);
 
     if (!defined($devices_list->{$scsihwid})) {
-        vm_deviceplug($storecfg, $conf, $vmid, $scsihwid, $device, $hw_config, $machine_type);
+        vm_deviceplug($storecfg, $conf, $vmid, $scsihwid, $device, $hw_config);
     }
 
     return 1;
@@ -4223,7 +4210,7 @@ sub qemu_netdevdel {
 }
 
 sub qemu_usb_hotplug {
-    my ($storecfg, $conf, $vmid, $deviceid, $device, $hw_config, $machine_type) = @_;
+    my ($storecfg, $conf, $vmid, $deviceid, $device, $hw_config) = @_;
 
     return if !$device;
 
@@ -4241,7 +4228,7 @@ sub qemu_usb_hotplug {
     }
 
     # add the new one
-    eval { vm_deviceplug($storecfg, $conf, $vmid, $deviceid, $device, $hw_config, $machine_type); };
+    eval { vm_deviceplug($storecfg, $conf, $vmid, $deviceid, $device, $hw_config); };
     if (my $err = $@) {
         if ($added_xhci) {
             eval { vm_deviceunplug($vmid, $conf, 'xhci'); };
@@ -4567,23 +4554,17 @@ sub vmconfig_hotplug_pending {
     my $defaults = load_defaults();
     my $arch = PVE::QemuServer::Helpers::get_vm_arch($conf);
 
-    my $_machine_type;
-    my $machine_type = sub {
-        return $_machine_type if defined($_machine_type);
-        $_machine_type = PVE::QemuServer::Machine::get_current_qemu_machine($vmid);
-        return $_machine_type;
-    };
-
     my $_hw_config;
     my $hw_config = sub {
         return $_hw_config if defined($_hw_config);
+        my $machine_type = PVE::QemuServer::Machine::get_current_qemu_machine($vmid);
 
         my $max_scsihw = PVE::QemuServer::DriveDevice::get_max_scsihw_index($conf);
         $_hw_config = PVE::QemuServer::PCI::hardware_config(
             $conf,
             $arch,
             $max_scsihw,
-            PVE::QemuServer::Machine::extract_version($machine_type->()),
+            PVE::QemuServer::Machine::extract_version($machine_type),
         );
 
         return $_hw_config;
@@ -4686,12 +4667,11 @@ sub vmconfig_hotplug_pending {
     my $is_usb_hotplug_supported = sub {
         return $usb_hotplug if defined($usb_hotplug);
         my $ostype = $conf->{ostype};
-        my $version = PVE::QemuServer::Machine::extract_version($machine_type->());
         $usb_hotplug =
             $hotplug_features->{usb}
-            && min_version($version, 7, 1)
+            && $hw_config->()->version_at_least(7, 1)
             && defined($ostype)
-            && ($ostype eq 'l26' || windows_version($ostype) > 7) ? 1 : 0;
+            && ($ostype eq 'l26' || $hw_config->()->windows_version() > 7) ? 1 : 0;
         return $usb_hotplug;
     };
 
@@ -4707,24 +4687,9 @@ sub vmconfig_hotplug_pending {
             } elsif ($opt eq 'tablet') {
                 die "skip\n" if !$hotplug_features->{usb};
                 if ($defaults->{tablet}) {
-                    vm_deviceplug(
-                        $storecfg,
-                        $conf,
-                        $vmid,
-                        'tablet',
-                        undef,
-                        $hw_config->(),
-                        $machine_type->(),
-                    );
-                    vm_deviceplug(
-                        $storecfg,
-                        $conf,
-                        $vmid,
-                        'keyboard',
-                        undef,
-                        $hw_config->(),
-                        $machine_type->(),
-                    ) if $arch eq 'aarch64';
+                    vm_deviceplug($storecfg, $conf, $vmid, 'tablet', undef, $hw_config->());
+                    vm_deviceplug($storecfg, $conf, $vmid, 'keyboard', undef, $hw_config->())
+                        if $arch eq 'aarch64';
                 } else {
                     vm_deviceunplug($vmid, $conf, 'tablet');
                     vm_deviceunplug($vmid, $conf, 'keyboard') if $arch eq 'aarch64';
@@ -4793,24 +4758,9 @@ sub vmconfig_hotplug_pending {
             } elsif ($opt eq 'tablet') {
                 die "skip\n" if !$hotplug_features->{usb};
                 if ($value == 1) {
-                    vm_deviceplug(
-                        $storecfg,
-                        $conf,
-                        $vmid,
-                        'tablet',
-                        undef,
-                        $hw_config->(),
-                        $machine_type->(),
-                    );
-                    vm_deviceplug(
-                        $storecfg,
-                        $conf,
-                        $vmid,
-                        'keyboard',
-                        undef,
-                        $hw_config->(),
-                        $machine_type->(),
-                    ) if $arch eq 'aarch64';
+                    vm_deviceplug($storecfg, $conf, $vmid, 'tablet', undef, $hw_config->());
+                    vm_deviceplug($storecfg, $conf, $vmid, 'keyboard', undef, $hw_config->())
+                        if $arch eq 'aarch64';
                 } elsif ($value == 0) {
                     vm_deviceunplug($vmid, $conf, 'tablet');
                     vm_deviceunplug($vmid, $conf, 'keyboard') if $arch eq 'aarch64';
@@ -4823,9 +4773,7 @@ sub vmconfig_hotplug_pending {
                 if ($d->{host} =~ m/^spice$/i) {
                     $id = "usbredirdev$index";
                 }
-                qemu_usb_hotplug(
-                    $storecfg, $conf, $vmid, $id, $d, $hw_config->(), $machine_type->(),
-                );
+                qemu_usb_hotplug($storecfg, $conf, $vmid, $id, $d, $hw_config->());
             } elsif ($opt eq 'vcpus') {
                 die "skip\n" if !$hotplug_features->{cpu};
                 qemu_cpu_hotplug($vmid, $conf, $value);
@@ -4852,7 +4800,6 @@ sub vmconfig_hotplug_pending {
                     $opt,
                     $value,
                     $hw_config->(),
-                    $machine_type->(),
                 );
             } elsif (is_valid_drivename($opt)) {
                 die "skip\n" if $opt eq 'efidisk0' || $opt eq 'tpmstate0';
@@ -4871,7 +4818,6 @@ sub vmconfig_hotplug_pending {
                     $opt,
                     $value,
                     $hw_config->(),
-                    $machine_type->(),
                 );
             } elsif ($opt =~ m/^memory$/) { #dimms
                 die "skip\n" if !$hotplug_features->{memory};
@@ -4912,7 +4858,6 @@ sub vmconfig_hotplug_pending {
                 $opt,
                 $value,
                 $hw_config->(),
-                $machine_type->(),
             );
         };
         if (my $err = $@) {
@@ -5106,7 +5051,7 @@ sub vmconfig_apply_pending {
 }
 
 sub vmconfig_update_net {
-    my ($storecfg, $conf, $hotplug, $vmid, $opt, $value, $hw_config, $machine_type) = @_;
+    my ($storecfg, $conf, $hotplug, $vmid, $opt, $value, $hw_config) = @_;
 
     my $newnet = PVE::QemuServer::Network::parse_net($value);
 
@@ -5198,7 +5143,7 @@ sub vmconfig_update_net {
         PVE::Network::SDN::Vnets::add_dhcp_mapping(
             $newnet->{bridge}, $newnet->{macaddr}, $vmid, $conf->{name},
         );
-        vm_deviceplug($storecfg, $conf, $vmid, $opt, $newnet, $hw_config, $machine_type);
+        vm_deviceplug($storecfg, $conf, $vmid, $opt, $newnet, $hw_config);
     } else {
         die "skip\n";
     }
@@ -5228,7 +5173,7 @@ sub vmconfig_update_agent {
 }
 
 sub vmconfig_update_disk {
-    my ($storecfg, $conf, $hotplug, $vmid, $opt, $value, $hw_config, $machine_type) = @_;
+    my ($storecfg, $conf, $hotplug, $vmid, $opt, $value, $hw_config) = @_;
 
     my $drive = parse_drive($opt, $value);
 
@@ -5334,7 +5279,7 @@ sub vmconfig_update_disk {
     die "skip\n" if !$hotplug || $opt =~ m/(ide|sata)(\d+)/;
     # hotplug new disks
     PVE::Storage::activate_volumes($storecfg, [$drive->{file}]) if $drive->{file} !~ m|^/dev/.+|;
-    vm_deviceplug($storecfg, $conf, $vmid, $opt, $drive, $hw_config, $machine_type);
+    vm_deviceplug($storecfg, $conf, $vmid, $opt, $drive, $hw_config);
 }
 
 sub vmconfig_update_cloudinit_drive {
diff --git a/src/PVE/QemuServer/Drive.pm b/src/PVE/QemuServer/Drive.pm
index 0b573717..769f2e0d 100644
--- a/src/PVE/QemuServer/Drive.pm
+++ b/src/PVE/QemuServer/Drive.pm
@@ -1115,7 +1115,7 @@ sub path_is_scsi {
 }
 
 sub get_scsi_device_type {
-    my ($drive, $storecfg, $machine_version) = @_;
+    my ($drive, $storecfg, $hw_config) = @_;
 
     my $devicetype = 'hd';
     my $path = '';
@@ -1139,10 +1139,7 @@ sub get_scsi_device_type {
         }
 
         # for compatibility only, we prefer scsi-hd (#2408, #2355, #2380)
-        if (
-            $path =~ m/^iscsi\:\/\//
-            && !PVE::QemuServer::Helpers::min_version($machine_version, 4, 1)
-        ) {
+        if ($path =~ m/^iscsi\:\/\// && !$hw_config->version_at_least(4, 1)) {
             $devicetype = 'generic';
         }
     }
diff --git a/src/PVE/QemuServer/DriveDevice.pm b/src/PVE/QemuServer/DriveDevice.pm
index 28588433..4303b664 100644
--- a/src/PVE/QemuServer/DriveDevice.pm
+++ b/src/PVE/QemuServer/DriveDevice.pm
@@ -6,7 +6,6 @@ use warnings;
 use URI::Escape;
 
 use PVE::QemuServer::Drive qw (drive_is_cdrom);
-use PVE::QemuServer::Helpers qw(kvm_user_version min_version);
 use PVE::QemuServer::Machine;
 
 use base qw(Exporter);
@@ -68,16 +67,14 @@ sub get_max_scsihw_index {
 }
 
 sub print_drivedevice_full {
-    my ($storecfg, $conf, $vmid, $drive, $hw_config, $machine_type) = @_;
+    my ($storecfg, $conf, $vmid, $drive, $hw_config) = @_;
 
     my $device = '';
     my $maxdev = 0;
 
-    my $machine_version =
-        PVE::QemuServer::Machine::extract_version($machine_type, kvm_user_version());
     my $has_write_cache = 1; # whether the device has a 'write-cache' option
 
-    my $use_blockdev = min_version($machine_version, 10, 0);
+    my $use_blockdev = $hw_config->version_at_least(10, 0);
 
     my $drive_id = PVE::QemuServer::Drive::get_drive_id($drive);
     if ($drive->{interface} eq 'virtio') {
@@ -96,7 +93,7 @@ sub print_drivedevice_full {
         my $unit = $drive->{index} % $maxdev;
 
         my $device_type =
-            PVE::QemuServer::Drive::get_scsi_device_type($drive, $storecfg, $machine_version);
+            PVE::QemuServer::Drive::get_scsi_device_type($drive, $storecfg, $hw_config);
 
         if (!$conf->{scsihw} || $conf->{scsihw} =~ m/^lsi/ || $conf->{scsihw} eq 'pvscsi') {
             $device = "scsi-$device_type,bus=$controller_prefix$controller.0,scsi-id=$unit";
diff --git a/src/PVE/QemuServer/USB.pm b/src/PVE/QemuServer/USB.pm
index f5e2e31f..42b4d3a3 100644
--- a/src/PVE/QemuServer/USB.pm
+++ b/src/PVE/QemuServer/USB.pm
@@ -3,7 +3,7 @@ package PVE::QemuServer::USB;
 use strict;
 use warnings;
 use PVE::QemuServer::Machine;
-use PVE::QemuServer::Helpers qw(min_version windows_version);
+use PVE::QemuServer::Helpers qw(windows_version);
 use PVE::JSONSchema;
 use PVE::Mapping::USB;
 use base 'Exporter';
@@ -126,7 +126,7 @@ sub get_default_usb_bus {
 }
 
 sub get_usb_controllers {
-    my ($conf, $hw_config, $machine_version) = @_;
+    my ($conf, $hw_config) = @_;
 
     my $devices = [];
     my $pciaddr = "";
@@ -134,7 +134,7 @@ sub get_usb_controllers {
     my $ostype = $conf->{ostype};
 
     my $use_qemu_xhci =
-        min_version($machine_version, 7, 1)
+        $hw_config->version_at_least(7, 1)
         && defined($ostype)
         && ($ostype eq 'l26' || windows_version($ostype) > 7);
 
@@ -175,13 +175,13 @@ sub get_usb_controllers {
 }
 
 sub get_usb_devices {
-    my ($conf, $bootorder, $machine_version) = @_;
+    my ($conf, $bootorder, $hw_config) = @_;
 
     my $devices = [];
 
     my $ostype = $conf->{ostype};
     my $use_qemu_xhci =
-        min_version($machine_version, 7, 1)
+        $hw_config->version_at_least(7, 1)
         && defined($ostype)
         && ($ostype eq 'l26' || windows_version($ostype) > 7);
 
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 12+ messages in thread

* [PATCH qemu-server v2 8/9] cfg2cmd: q35: generate chipset devices with machine version 11.1+pve1
  2026-10-15  8:10 [RFC proxmox-perl-rs/qemu-server/qemu-server-rs v2 0/9] pci-handling rewrite (part 1/2/3) Dominik Csapak
                   ` (6 preceding siblings ...)
  2026-10-15  8:10 ` [PATCH qemu-server v2 7/9] tree-wide: use HardwareConfig for the machine version checks Dominik Csapak
@ 2026-10-15  8:10 ` Dominik Csapak
  2026-10-15  8:10 ` [PATCH qemu-server v2 9/9] machine: add 'pci-layout' option Dominik Csapak
  8 siblings, 0 replies; 12+ messages in thread
From: Dominik Csapak @ 2026-10-15  8:10 UTC (permalink / raw)
  To: pve-devel

Instead of loading the pve-q35-4.0.cfg machine config file, let
`pci_bus_devices` generate the chipset devices. Since this must not
change the devices of running guests for live migration, only do that
starting with the new machine version 11.1+pve1. Older machines keep
using the config file.

Since the tests run with QEMU 11.1, the q35 tests without a pinned
machine version now get the new revision and thus the generated
devices. The tests with an older pinned machine version, as well as
the Windows ones, which get pinned to their creation version, keep
covering the config file.

Add a test with hostpci devices and machine version 11.1+pve1 pinned,
so the root ports are covered in combination with the generated
chipset, independent of the QEMU version the tests run with.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 src/PVE/QemuServer.pm                         |  2 +-
 src/PVE/QemuServer/Machine.pm                 |  6 ++
 .../cfg2cmd/efi-secboot-and-tpm-q35.conf.cmd  | 21 ++++++-
 src/test/cfg2cmd/q35-ide.conf.cmd             | 21 ++++++-
 .../cfg2cmd/q35-linux-hostpci-11.1+pve1.conf  | 21 +++++++
 .../q35-linux-hostpci-11.1+pve1.conf.cmd      | 61 +++++++++++++++++++
 .../q35-linux-hostpci-driver-keep.conf.cmd    | 21 ++++++-
 .../q35-linux-hostpci-mapping.conf.cmd        | 21 ++++++-
 .../q35-linux-hostpci-multifunction.conf.cmd  | 21 ++++++-
 ...q35-linux-hostpci-x-pci-overrides.conf.cmd | 21 ++++++-
 src/test/cfg2cmd/q35-linux-hostpci.conf.cmd   | 21 ++++++-
 src/test/cfg2cmd/q35-linux-ivshmem.conf.cmd   | 21 ++++++-
 src/test/cfg2cmd/q35-scsihw2.conf.cmd         | 21 ++++++-
 src/test/cfg2cmd/q35-simple.conf.cmd          | 21 ++++++-
 .../cfg2cmd/q35-viommu-intel-aw-bits.conf.cmd | 21 ++++++-
 .../q35-viommu-intel-guest-phys-bits.conf.cmd | 21 ++++++-
 src/test/cfg2cmd/q35-viommu-intel.conf.cmd    | 21 ++++++-
 .../q35-viommu-virtio-aw-bits.conf.cmd        | 21 ++++++-
 src/test/cfg2cmd/q35-viommu-virtio.conf.cmd   | 21 ++++++-
 19 files changed, 374 insertions(+), 31 deletions(-)
 create mode 100644 src/test/cfg2cmd/q35-linux-hostpci-11.1+pve1.conf
 create mode 100644 src/test/cfg2cmd/q35-linux-hostpci-11.1+pve1.conf.cmd

diff --git a/src/PVE/QemuServer.pm b/src/PVE/QemuServer.pm
index d6a5ed6b..fe16e44b 100644
--- a/src/PVE/QemuServer.pm
+++ b/src/PVE/QemuServer.pm
@@ -3264,7 +3264,7 @@ sub config_to_command {
         $conf, $arch, $max_scsihw, $machine_version, $forcemachine,
     );
 
-    if ($q35) { # tell QEMU to load q35 config early
+    if ($hw_config->uses_q35_config_file()) {
         # we use different pcie-port hardware for qemu >= 4.0 for passthrough
         if (min_version($machine_version, 4, 0)) {
             push @$devices, '-readconfig', '/usr/share/qemu-server/pve-q35-4.0.cfg';
diff --git a/src/PVE/QemuServer/Machine.pm b/src/PVE/QemuServer/Machine.pm
index 35161d1f..e60377b2 100644
--- a/src/PVE/QemuServer/Machine.pm
+++ b/src/PVE/QemuServer/Machine.pm
@@ -50,6 +50,12 @@ our $PVE_MACHINE_VERSION = {
             '+pve2' => 'Enable hv_emcs for Windows 11/2022/2025 to mitigate issues with VBS.',
         },
     },
+    '11.1' => {
+        highest => 1,
+        revisions => {
+            '+pve1' => 'Generate the q35 chipset devices instead of loading pve-q35-4.0.cfg.',
+        },
+    },
 };
 
 my $machine_fmt = {
diff --git a/src/test/cfg2cmd/efi-secboot-and-tpm-q35.conf.cmd b/src/test/cfg2cmd/efi-secboot-and-tpm-q35.conf.cmd
index ab8e323c..3f30a66f 100644
--- a/src/test/cfg2cmd/efi-secboot-and-tpm-q35.conf.cmd
+++ b/src/test/cfg2cmd/efi-secboot-and-tpm-q35.conf.cmd
@@ -20,7 +20,24 @@
   -m 512 \
   -global 'ICH9-LPC.disable_s3=1' \
   -global 'ICH9-LPC.disable_s4=1' \
-  -readconfig /usr/share/qemu-server/pve-q35-4.0.cfg \
+  -device 'ich9-usb-ehci1,id=ehci,multifunction=on,bus=pcie.0,addr=0x1d.7' \
+  -device 'ich9-usb-uhci1,id=uhci-1,multifunction=on,bus=pcie.0,addr=0x1d,masterbus=ehci.0,firstport=0' \
+  -device 'ich9-usb-uhci2,id=uhci-2,multifunction=on,bus=pcie.0,addr=0x1d.1,masterbus=ehci.0,firstport=2' \
+  -device 'ich9-usb-uhci3,id=uhci-3,multifunction=on,bus=pcie.0,addr=0x1d.2,masterbus=ehci.0,firstport=4' \
+  -device 'ich9-usb-ehci2,id=ehci-2,multifunction=on,bus=pcie.0,addr=0x1a.7' \
+  -device 'ich9-usb-uhci4,id=uhci-4,multifunction=on,bus=pcie.0,addr=0x1a,masterbus=ehci-2.0,firstport=0' \
+  -device 'ich9-usb-uhci5,id=uhci-5,multifunction=on,bus=pcie.0,addr=0x1a.1,masterbus=ehci-2.0,firstport=2' \
+  -device 'ich9-usb-uhci6,id=uhci-6,multifunction=on,bus=pcie.0,addr=0x1a.2,masterbus=ehci-2.0,firstport=4' \
+  -device 'ich9-intel-hda,id=audio0,bus=pcie.0,addr=0x1b' \
+  -device 'pcie-root-port,id=ich9-pcie-port-1,addr=1c.0,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=1,chassis=1' \
+  -device 'pcie-root-port,id=ich9-pcie-port-2,addr=1c.1,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=2,chassis=2' \
+  -device 'pcie-root-port,id=ich9-pcie-port-3,addr=1c.2,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=3,chassis=3' \
+  -device 'pcie-root-port,id=ich9-pcie-port-4,addr=1c.3,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=4,chassis=4' \
+  -device 'i82801b11-bridge,id=pcidmi,bus=pcie.0,addr=0x1e' \
+  -device 'pci-bridge,id=pci.0,chassis_nr=1,bus=pcidmi,addr=0x1' \
+  -device 'pci-bridge,id=pci.1,chassis_nr=2,bus=pcidmi,addr=0x2' \
+  -device 'pci-bridge,id=pci.2,chassis_nr=3,bus=pcidmi,addr=0x3' \
+  -device 'pci-bridge,id=pci.3,chassis_nr=4,bus=pcidmi,addr=0x4' \
   -device 'pci-bridge,id=pci.4,chassis_nr=4,bus=pci.1,addr=0x1c' \
   -device 'usb-tablet,id=tablet,bus=ehci.0,port=1' \
   -chardev 'socket,id=tpmchar,path=/var/run/qemu-server/8006.swtpm' \
@@ -29,4 +46,4 @@
   -device 'VGA,id=vga,bus=pcie.0,addr=0x1' \
   -device 'virtio-balloon-pci,id=balloon0,bus=pci.0,addr=0x3,free-page-reporting=on' \
   -iscsi 'initiator-name=iqn.1993-08.org.debian:01:aabbccddeeff' \
-  -machine 'pflash0=pflash0,pflash1=drive-efidisk0,type=q35+pve0'
+  -machine 'pflash0=pflash0,pflash1=drive-efidisk0,type=q35+pve1'
diff --git a/src/test/cfg2cmd/q35-ide.conf.cmd b/src/test/cfg2cmd/q35-ide.conf.cmd
index 242ccf62..02b44fba 100644
--- a/src/test/cfg2cmd/q35-ide.conf.cmd
+++ b/src/test/cfg2cmd/q35-ide.conf.cmd
@@ -23,7 +23,24 @@
   -object '{"id":"throttle-drive-scsi0","limits":{},"qom-type":"throttle-group"}' \
   -global 'ICH9-LPC.disable_s3=1' \
   -global 'ICH9-LPC.disable_s4=1' \
-  -readconfig /usr/share/qemu-server/pve-q35-4.0.cfg \
+  -device 'ich9-usb-ehci1,id=ehci,multifunction=on,bus=pcie.0,addr=0x1d.7' \
+  -device 'ich9-usb-uhci1,id=uhci-1,multifunction=on,bus=pcie.0,addr=0x1d,masterbus=ehci.0,firstport=0' \
+  -device 'ich9-usb-uhci2,id=uhci-2,multifunction=on,bus=pcie.0,addr=0x1d.1,masterbus=ehci.0,firstport=2' \
+  -device 'ich9-usb-uhci3,id=uhci-3,multifunction=on,bus=pcie.0,addr=0x1d.2,masterbus=ehci.0,firstport=4' \
+  -device 'ich9-usb-ehci2,id=ehci-2,multifunction=on,bus=pcie.0,addr=0x1a.7' \
+  -device 'ich9-usb-uhci4,id=uhci-4,multifunction=on,bus=pcie.0,addr=0x1a,masterbus=ehci-2.0,firstport=0' \
+  -device 'ich9-usb-uhci5,id=uhci-5,multifunction=on,bus=pcie.0,addr=0x1a.1,masterbus=ehci-2.0,firstport=2' \
+  -device 'ich9-usb-uhci6,id=uhci-6,multifunction=on,bus=pcie.0,addr=0x1a.2,masterbus=ehci-2.0,firstport=4' \
+  -device 'ich9-intel-hda,id=audio0,bus=pcie.0,addr=0x1b' \
+  -device 'pcie-root-port,id=ich9-pcie-port-1,addr=1c.0,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=1,chassis=1' \
+  -device 'pcie-root-port,id=ich9-pcie-port-2,addr=1c.1,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=2,chassis=2' \
+  -device 'pcie-root-port,id=ich9-pcie-port-3,addr=1c.2,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=3,chassis=3' \
+  -device 'pcie-root-port,id=ich9-pcie-port-4,addr=1c.3,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=4,chassis=4' \
+  -device 'i82801b11-bridge,id=pcidmi,bus=pcie.0,addr=0x1e' \
+  -device 'pci-bridge,id=pci.0,chassis_nr=1,bus=pcidmi,addr=0x1' \
+  -device 'pci-bridge,id=pci.1,chassis_nr=2,bus=pcidmi,addr=0x2' \
+  -device 'pci-bridge,id=pci.2,chassis_nr=3,bus=pcidmi,addr=0x3' \
+  -device 'pci-bridge,id=pci.3,chassis_nr=4,bus=pcidmi,addr=0x4' \
   -device 'pci-bridge,id=pci.4,chassis_nr=4,bus=pci.1,addr=0x1c' \
   -device 'vmgenid,guid=54d1c06c-8f5b-440f-b5b2-6eab1380e13d' \
   -device 'usb-tablet,id=tablet,bus=ehci.0,port=1' \
@@ -43,4 +60,4 @@
   -device 'scsi-hd,bus=scsihw0.0,channel=0,scsi-id=0,lun=0,drive=drive-scsi0,id=scsi0,device_id=drive-scsi0,bootindex=100,write-cache=on' \
   -netdev 'type=tap,id=net0,ifname=tap8006i0,script=/usr/libexec/qemu-server/pve-bridge,downscript=/usr/libexec/qemu-server/pve-bridgedown,vhost=on' \
   -device 'virtio-net-pci,mac=2E:01:68:F9:9C:87,netdev=net0,bus=pci.0,addr=0x12,id=net0,rx_queue_size=1024,tx_queue_size=256,bootindex=300,host_mtu=1500,host_tunnel=off' \
-  -machine 'hpet=off,type=q35+pve0'
+  -machine 'hpet=off,type=q35+pve1'
diff --git a/src/test/cfg2cmd/q35-linux-hostpci-11.1+pve1.conf b/src/test/cfg2cmd/q35-linux-hostpci-11.1+pve1.conf
new file mode 100644
index 00000000..6d71dbc8
--- /dev/null
+++ b/src/test/cfg2cmd/q35-linux-hostpci-11.1+pve1.conf
@@ -0,0 +1,21 @@
+# TEST: q35 with hostpci and machine version 11.1+pve1 generates the chipset devices and root ports
+# QEMU_VERSION: 11.1.0
+bios: ovmf
+bootdisk: scsi0
+cores: 1
+efidisk0: local:100/vm-100-disk-1.qcow2,size=128K
+hostpci0: 00:ff.1
+hostpci1: d0:13.0,pcie=1
+hostpci2: 00:f4.0
+hostpci3: d0:15.1,pcie=1
+hostpci4: d0:17.0,pcie=1,rombar=0
+hostpci7: d0:15.2,pcie=1
+machine: pc-q35-11.1+pve1
+memory: 512
+net0: virtio=2E:01:68:F9:9C:87,bridge=vmbr0
+numa: 1
+ostype: l26
+scsihw: virtio-scsi-pci
+smbios1: uuid=3dd750ce-d910-44d0-9493-525c0be4e687
+sockets: 2
+vmgenid: 54d1c06c-8f5b-440f-b5b2-6eab1380e13d
diff --git a/src/test/cfg2cmd/q35-linux-hostpci-11.1+pve1.conf.cmd b/src/test/cfg2cmd/q35-linux-hostpci-11.1+pve1.conf.cmd
new file mode 100644
index 00000000..c76be284
--- /dev/null
+++ b/src/test/cfg2cmd/q35-linux-hostpci-11.1+pve1.conf.cmd
@@ -0,0 +1,61 @@
+/usr/bin/kvm \
+  -id 8006 \
+  -name vm8006 \
+  -no-shutdown \
+  -chardev 'socket,id=qmp,path=/var/run/qemu-server/8006.qmp,server=on,wait=off' \
+  -object 'monitor-qmp,chardev=qmp,id=monitor-qmp' \
+  -chardev 'socket,id=qmp-event,path=/var/run/qmeventd.sock,reconnect-ms=5000' \
+  -object 'monitor-qmp,chardev=qmp-event,id=monitor-qmp-event' \
+  -pidfile /var/run/qemu-server/8006.pid \
+  -daemonize \
+  -smbios 'type=1,uuid=3dd750ce-d910-44d0-9493-525c0be4e687' \
+  -object '{"id":"throttle-drive-efidisk0","limits":{},"qom-type":"throttle-group"}' \
+  -blockdev '{"driver":"raw","file":{"driver":"file","filename":"/usr/share/pve-edk2-firmware//OVMF_CODE.fd"},"node-name":"pflash0","read-only":true}' \
+  -blockdev '{"detect-zeroes":"on","discard":"ignore","driver":"throttle","file":{"cache":{"direct":false,"no-flush":false},"detect-zeroes":"on","discard":"ignore","driver":"qcow2","file":{"aio":"io_uring","cache":{"direct":false,"no-flush":false},"detect-zeroes":"on","discard":"ignore","driver":"file","filename":"/var/lib/vz/images/100/vm-100-disk-1.qcow2","node-name":"e70e3017c5a79fdee5a04aa92ac1e9c","read-only":false},"node-name":"f70e3017c5a79fdee5a04aa92ac1e9c","read-only":false},"node-name":"drive-efidisk0","read-only":false,"throttle-group":"throttle-drive-efidisk0"}' \
+  -smp '2,sockets=2,cores=1,maxcpus=2' \
+  -nodefaults \
+  -boot 'menu=on,strict=on,reboot-timeout=1000,splash=/usr/share/qemu-server/bootsplash.jpg' \
+  -vnc 'unix:/var/run/qemu-server/8006.vnc,password=on' \
+  -cpu kvm64,enforce,+kvm_pv_eoi,+kvm_pv_unhalt,+lahf_lm,+sep \
+  -m 512 \
+  -object 'memory-backend-ram,id=ram-node0,size=256M' \
+  -numa 'node,nodeid=0,cpus=0,memdev=ram-node0' \
+  -object 'memory-backend-ram,id=ram-node1,size=256M' \
+  -numa 'node,nodeid=1,cpus=1,memdev=ram-node1' \
+  -global 'ICH9-LPC.disable_s3=1' \
+  -global 'ICH9-LPC.disable_s4=1' \
+  -device 'ich9-usb-ehci1,id=ehci,multifunction=on,bus=pcie.0,addr=0x1d.7' \
+  -device 'ich9-usb-uhci1,id=uhci-1,multifunction=on,bus=pcie.0,addr=0x1d,masterbus=ehci.0,firstport=0' \
+  -device 'ich9-usb-uhci2,id=uhci-2,multifunction=on,bus=pcie.0,addr=0x1d.1,masterbus=ehci.0,firstport=2' \
+  -device 'ich9-usb-uhci3,id=uhci-3,multifunction=on,bus=pcie.0,addr=0x1d.2,masterbus=ehci.0,firstport=4' \
+  -device 'ich9-usb-ehci2,id=ehci-2,multifunction=on,bus=pcie.0,addr=0x1a.7' \
+  -device 'ich9-usb-uhci4,id=uhci-4,multifunction=on,bus=pcie.0,addr=0x1a,masterbus=ehci-2.0,firstport=0' \
+  -device 'ich9-usb-uhci5,id=uhci-5,multifunction=on,bus=pcie.0,addr=0x1a.1,masterbus=ehci-2.0,firstport=2' \
+  -device 'ich9-usb-uhci6,id=uhci-6,multifunction=on,bus=pcie.0,addr=0x1a.2,masterbus=ehci-2.0,firstport=4' \
+  -device 'ich9-intel-hda,id=audio0,bus=pcie.0,addr=0x1b' \
+  -device 'pcie-root-port,id=ich9-pcie-port-1,addr=1c.0,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=1,chassis=1' \
+  -device 'pcie-root-port,id=ich9-pcie-port-2,addr=1c.1,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=2,chassis=2' \
+  -device 'pcie-root-port,id=ich9-pcie-port-3,addr=1c.2,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=3,chassis=3' \
+  -device 'pcie-root-port,id=ich9-pcie-port-4,addr=1c.3,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=4,chassis=4' \
+  -device 'i82801b11-bridge,id=pcidmi,bus=pcie.0,addr=0x1e' \
+  -device 'pci-bridge,id=pci.0,chassis_nr=1,bus=pcidmi,addr=0x1' \
+  -device 'pci-bridge,id=pci.1,chassis_nr=2,bus=pcidmi,addr=0x2' \
+  -device 'pci-bridge,id=pci.2,chassis_nr=3,bus=pcidmi,addr=0x3' \
+  -device 'pci-bridge,id=pci.3,chassis_nr=4,bus=pcidmi,addr=0x4' \
+  -device 'pci-bridge,id=pci.4,chassis_nr=4,bus=pci.1,addr=0x1c' \
+  -device 'pcie-root-port,id=ich9-pcie-port-5,addr=10.0,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=5,chassis=5' \
+  -device 'pcie-root-port,id=ich9-pcie-port-8,addr=10.3,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=8,chassis=8' \
+  -device 'vmgenid,guid=54d1c06c-8f5b-440f-b5b2-6eab1380e13d' \
+  -device 'usb-tablet,id=tablet,bus=ehci.0,port=1' \
+  -device 'vfio-pci,host=0000:00:ff.1,id=hostpci0,bus=pci.0,addr=0x10' \
+  -device 'vfio-pci,host=0000:d0:13.0,id=hostpci1,bus=ich9-pcie-port-2,addr=0x0' \
+  -device 'vfio-pci,host=0000:00:f4.0,id=hostpci2,bus=pci.0,addr=0x1b' \
+  -device 'vfio-pci,host=0000:d0:15.1,id=hostpci3,bus=ich9-pcie-port-4,addr=0x0' \
+  -device 'vfio-pci,host=0000:d0:17.0,id=hostpci4,bus=ich9-pcie-port-5,addr=0x0,rombar=0' \
+  -device 'vfio-pci,host=0000:d0:15.2,id=hostpci7,bus=ich9-pcie-port-8,addr=0x0' \
+  -device 'VGA,id=vga,bus=pcie.0,addr=0x1' \
+  -device 'virtio-balloon-pci,id=balloon0,bus=pci.0,addr=0x3,free-page-reporting=on' \
+  -iscsi 'initiator-name=iqn.1993-08.org.debian:01:aabbccddeeff' \
+  -netdev 'type=tap,id=net0,ifname=tap8006i0,script=/usr/libexec/qemu-server/pve-bridge,downscript=/usr/libexec/qemu-server/pve-bridgedown,vhost=on' \
+  -device 'virtio-net-pci,mac=2E:01:68:F9:9C:87,netdev=net0,bus=pci.0,addr=0x12,id=net0,rx_queue_size=1024,tx_queue_size=256,bootindex=300,host_mtu=1500,host_tunnel=off' \
+  -machine 'pflash0=pflash0,pflash1=drive-efidisk0,hpet=off,type=pc-q35-11.1+pve1'
diff --git a/src/test/cfg2cmd/q35-linux-hostpci-driver-keep.conf.cmd b/src/test/cfg2cmd/q35-linux-hostpci-driver-keep.conf.cmd
index d64264ec..ac7b8720 100644
--- a/src/test/cfg2cmd/q35-linux-hostpci-driver-keep.conf.cmd
+++ b/src/test/cfg2cmd/q35-linux-hostpci-driver-keep.conf.cmd
@@ -25,7 +25,24 @@
   -numa 'node,nodeid=1,cpus=1,memdev=ram-node1' \
   -global 'ICH9-LPC.disable_s3=1' \
   -global 'ICH9-LPC.disable_s4=1' \
-  -readconfig /usr/share/qemu-server/pve-q35-4.0.cfg \
+  -device 'ich9-usb-ehci1,id=ehci,multifunction=on,bus=pcie.0,addr=0x1d.7' \
+  -device 'ich9-usb-uhci1,id=uhci-1,multifunction=on,bus=pcie.0,addr=0x1d,masterbus=ehci.0,firstport=0' \
+  -device 'ich9-usb-uhci2,id=uhci-2,multifunction=on,bus=pcie.0,addr=0x1d.1,masterbus=ehci.0,firstport=2' \
+  -device 'ich9-usb-uhci3,id=uhci-3,multifunction=on,bus=pcie.0,addr=0x1d.2,masterbus=ehci.0,firstport=4' \
+  -device 'ich9-usb-ehci2,id=ehci-2,multifunction=on,bus=pcie.0,addr=0x1a.7' \
+  -device 'ich9-usb-uhci4,id=uhci-4,multifunction=on,bus=pcie.0,addr=0x1a,masterbus=ehci-2.0,firstport=0' \
+  -device 'ich9-usb-uhci5,id=uhci-5,multifunction=on,bus=pcie.0,addr=0x1a.1,masterbus=ehci-2.0,firstport=2' \
+  -device 'ich9-usb-uhci6,id=uhci-6,multifunction=on,bus=pcie.0,addr=0x1a.2,masterbus=ehci-2.0,firstport=4' \
+  -device 'ich9-intel-hda,id=audio0,bus=pcie.0,addr=0x1b' \
+  -device 'pcie-root-port,id=ich9-pcie-port-1,addr=1c.0,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=1,chassis=1' \
+  -device 'pcie-root-port,id=ich9-pcie-port-2,addr=1c.1,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=2,chassis=2' \
+  -device 'pcie-root-port,id=ich9-pcie-port-3,addr=1c.2,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=3,chassis=3' \
+  -device 'pcie-root-port,id=ich9-pcie-port-4,addr=1c.3,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=4,chassis=4' \
+  -device 'i82801b11-bridge,id=pcidmi,bus=pcie.0,addr=0x1e' \
+  -device 'pci-bridge,id=pci.0,chassis_nr=1,bus=pcidmi,addr=0x1' \
+  -device 'pci-bridge,id=pci.1,chassis_nr=2,bus=pcidmi,addr=0x2' \
+  -device 'pci-bridge,id=pci.2,chassis_nr=3,bus=pcidmi,addr=0x3' \
+  -device 'pci-bridge,id=pci.3,chassis_nr=4,bus=pcidmi,addr=0x4' \
   -device 'pci-bridge,id=pci.4,chassis_nr=4,bus=pci.1,addr=0x1c' \
   -device 'pcie-root-port,id=ich9-pcie-port-5,addr=10.0,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=5,chassis=5' \
   -device 'pcie-root-port,id=ich9-pcie-port-8,addr=10.3,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=8,chassis=8' \
@@ -42,4 +59,4 @@
   -iscsi 'initiator-name=iqn.1993-08.org.debian:01:aabbccddeeff' \
   -netdev 'type=tap,id=net0,ifname=tap8006i0,script=/usr/libexec/qemu-server/pve-bridge,downscript=/usr/libexec/qemu-server/pve-bridgedown,vhost=on' \
   -device 'virtio-net-pci,mac=2E:01:68:F9:9C:87,netdev=net0,bus=pci.0,addr=0x12,id=net0,rx_queue_size=1024,tx_queue_size=256,bootindex=300,host_mtu=1500,host_tunnel=off' \
-  -machine 'pflash0=pflash0,pflash1=drive-efidisk0,hpet=off,type=q35+pve0'
+  -machine 'pflash0=pflash0,pflash1=drive-efidisk0,hpet=off,type=q35+pve1'
diff --git a/src/test/cfg2cmd/q35-linux-hostpci-mapping.conf.cmd b/src/test/cfg2cmd/q35-linux-hostpci-mapping.conf.cmd
index b50827d2..e5910191 100644
--- a/src/test/cfg2cmd/q35-linux-hostpci-mapping.conf.cmd
+++ b/src/test/cfg2cmd/q35-linux-hostpci-mapping.conf.cmd
@@ -25,7 +25,24 @@
   -numa 'node,nodeid=1,cpus=1,memdev=ram-node1' \
   -global 'ICH9-LPC.disable_s3=1' \
   -global 'ICH9-LPC.disable_s4=1' \
-  -readconfig /usr/share/qemu-server/pve-q35-4.0.cfg \
+  -device 'ich9-usb-ehci1,id=ehci,multifunction=on,bus=pcie.0,addr=0x1d.7' \
+  -device 'ich9-usb-uhci1,id=uhci-1,multifunction=on,bus=pcie.0,addr=0x1d,masterbus=ehci.0,firstport=0' \
+  -device 'ich9-usb-uhci2,id=uhci-2,multifunction=on,bus=pcie.0,addr=0x1d.1,masterbus=ehci.0,firstport=2' \
+  -device 'ich9-usb-uhci3,id=uhci-3,multifunction=on,bus=pcie.0,addr=0x1d.2,masterbus=ehci.0,firstport=4' \
+  -device 'ich9-usb-ehci2,id=ehci-2,multifunction=on,bus=pcie.0,addr=0x1a.7' \
+  -device 'ich9-usb-uhci4,id=uhci-4,multifunction=on,bus=pcie.0,addr=0x1a,masterbus=ehci-2.0,firstport=0' \
+  -device 'ich9-usb-uhci5,id=uhci-5,multifunction=on,bus=pcie.0,addr=0x1a.1,masterbus=ehci-2.0,firstport=2' \
+  -device 'ich9-usb-uhci6,id=uhci-6,multifunction=on,bus=pcie.0,addr=0x1a.2,masterbus=ehci-2.0,firstport=4' \
+  -device 'ich9-intel-hda,id=audio0,bus=pcie.0,addr=0x1b' \
+  -device 'pcie-root-port,id=ich9-pcie-port-1,addr=1c.0,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=1,chassis=1' \
+  -device 'pcie-root-port,id=ich9-pcie-port-2,addr=1c.1,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=2,chassis=2' \
+  -device 'pcie-root-port,id=ich9-pcie-port-3,addr=1c.2,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=3,chassis=3' \
+  -device 'pcie-root-port,id=ich9-pcie-port-4,addr=1c.3,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=4,chassis=4' \
+  -device 'i82801b11-bridge,id=pcidmi,bus=pcie.0,addr=0x1e' \
+  -device 'pci-bridge,id=pci.0,chassis_nr=1,bus=pcidmi,addr=0x1' \
+  -device 'pci-bridge,id=pci.1,chassis_nr=2,bus=pcidmi,addr=0x2' \
+  -device 'pci-bridge,id=pci.2,chassis_nr=3,bus=pcidmi,addr=0x3' \
+  -device 'pci-bridge,id=pci.3,chassis_nr=4,bus=pcidmi,addr=0x4' \
   -device 'pci-bridge,id=pci.4,chassis_nr=4,bus=pci.1,addr=0x1c' \
   -device 'vmgenid,guid=54d1c06c-8f5b-440f-b5b2-6eab1380e13d' \
   -device 'usb-tablet,id=tablet,bus=ehci.0,port=1' \
@@ -37,4 +54,4 @@
   -iscsi 'initiator-name=iqn.1993-08.org.debian:01:aabbccddeeff' \
   -netdev 'type=tap,id=net0,ifname=tap8006i0,script=/usr/libexec/qemu-server/pve-bridge,downscript=/usr/libexec/qemu-server/pve-bridgedown,vhost=on' \
   -device 'virtio-net-pci,mac=2E:01:68:F9:9C:87,netdev=net0,bus=pci.0,addr=0x12,id=net0,rx_queue_size=1024,tx_queue_size=256,bootindex=300,host_mtu=1500,host_tunnel=off' \
-  -machine 'pflash0=pflash0,pflash1=drive-efidisk0,hpet=off,type=q35+pve0'
+  -machine 'pflash0=pflash0,pflash1=drive-efidisk0,hpet=off,type=q35+pve1'
diff --git a/src/test/cfg2cmd/q35-linux-hostpci-multifunction.conf.cmd b/src/test/cfg2cmd/q35-linux-hostpci-multifunction.conf.cmd
index 630f2a9b..fbd0e30b 100644
--- a/src/test/cfg2cmd/q35-linux-hostpci-multifunction.conf.cmd
+++ b/src/test/cfg2cmd/q35-linux-hostpci-multifunction.conf.cmd
@@ -25,7 +25,24 @@
   -numa 'node,nodeid=1,cpus=1,memdev=ram-node1' \
   -global 'ICH9-LPC.disable_s3=1' \
   -global 'ICH9-LPC.disable_s4=1' \
-  -readconfig /usr/share/qemu-server/pve-q35-4.0.cfg \
+  -device 'ich9-usb-ehci1,id=ehci,multifunction=on,bus=pcie.0,addr=0x1d.7' \
+  -device 'ich9-usb-uhci1,id=uhci-1,multifunction=on,bus=pcie.0,addr=0x1d,masterbus=ehci.0,firstport=0' \
+  -device 'ich9-usb-uhci2,id=uhci-2,multifunction=on,bus=pcie.0,addr=0x1d.1,masterbus=ehci.0,firstport=2' \
+  -device 'ich9-usb-uhci3,id=uhci-3,multifunction=on,bus=pcie.0,addr=0x1d.2,masterbus=ehci.0,firstport=4' \
+  -device 'ich9-usb-ehci2,id=ehci-2,multifunction=on,bus=pcie.0,addr=0x1a.7' \
+  -device 'ich9-usb-uhci4,id=uhci-4,multifunction=on,bus=pcie.0,addr=0x1a,masterbus=ehci-2.0,firstport=0' \
+  -device 'ich9-usb-uhci5,id=uhci-5,multifunction=on,bus=pcie.0,addr=0x1a.1,masterbus=ehci-2.0,firstport=2' \
+  -device 'ich9-usb-uhci6,id=uhci-6,multifunction=on,bus=pcie.0,addr=0x1a.2,masterbus=ehci-2.0,firstport=4' \
+  -device 'ich9-intel-hda,id=audio0,bus=pcie.0,addr=0x1b' \
+  -device 'pcie-root-port,id=ich9-pcie-port-1,addr=1c.0,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=1,chassis=1' \
+  -device 'pcie-root-port,id=ich9-pcie-port-2,addr=1c.1,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=2,chassis=2' \
+  -device 'pcie-root-port,id=ich9-pcie-port-3,addr=1c.2,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=3,chassis=3' \
+  -device 'pcie-root-port,id=ich9-pcie-port-4,addr=1c.3,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=4,chassis=4' \
+  -device 'i82801b11-bridge,id=pcidmi,bus=pcie.0,addr=0x1e' \
+  -device 'pci-bridge,id=pci.0,chassis_nr=1,bus=pcidmi,addr=0x1' \
+  -device 'pci-bridge,id=pci.1,chassis_nr=2,bus=pcidmi,addr=0x2' \
+  -device 'pci-bridge,id=pci.2,chassis_nr=3,bus=pcidmi,addr=0x3' \
+  -device 'pci-bridge,id=pci.3,chassis_nr=4,bus=pcidmi,addr=0x4' \
   -device 'pci-bridge,id=pci.4,chassis_nr=4,bus=pci.1,addr=0x1c' \
   -device 'vmgenid,guid=54d1c06c-8f5b-440f-b5b2-6eab1380e13d' \
   -device 'usb-tablet,id=tablet,bus=ehci.0,port=1' \
@@ -37,4 +54,4 @@
   -iscsi 'initiator-name=iqn.1993-08.org.debian:01:aabbccddeeff' \
   -netdev 'type=tap,id=net0,ifname=tap8006i0,script=/usr/libexec/qemu-server/pve-bridge,downscript=/usr/libexec/qemu-server/pve-bridgedown,vhost=on' \
   -device 'virtio-net-pci,mac=2E:01:68:F9:9C:87,netdev=net0,bus=pci.0,addr=0x12,id=net0,rx_queue_size=1024,tx_queue_size=256,bootindex=300,host_mtu=1500,host_tunnel=off' \
-  -machine 'pflash0=pflash0,pflash1=drive-efidisk0,hpet=off,type=q35+pve0'
+  -machine 'pflash0=pflash0,pflash1=drive-efidisk0,hpet=off,type=q35+pve1'
diff --git a/src/test/cfg2cmd/q35-linux-hostpci-x-pci-overrides.conf.cmd b/src/test/cfg2cmd/q35-linux-hostpci-x-pci-overrides.conf.cmd
index e1a02dce..0d27ee31 100644
--- a/src/test/cfg2cmd/q35-linux-hostpci-x-pci-overrides.conf.cmd
+++ b/src/test/cfg2cmd/q35-linux-hostpci-x-pci-overrides.conf.cmd
@@ -25,7 +25,24 @@
   -numa 'node,nodeid=1,cpus=1,memdev=ram-node1' \
   -global 'ICH9-LPC.disable_s3=1' \
   -global 'ICH9-LPC.disable_s4=1' \
-  -readconfig /usr/share/qemu-server/pve-q35-4.0.cfg \
+  -device 'ich9-usb-ehci1,id=ehci,multifunction=on,bus=pcie.0,addr=0x1d.7' \
+  -device 'ich9-usb-uhci1,id=uhci-1,multifunction=on,bus=pcie.0,addr=0x1d,masterbus=ehci.0,firstport=0' \
+  -device 'ich9-usb-uhci2,id=uhci-2,multifunction=on,bus=pcie.0,addr=0x1d.1,masterbus=ehci.0,firstport=2' \
+  -device 'ich9-usb-uhci3,id=uhci-3,multifunction=on,bus=pcie.0,addr=0x1d.2,masterbus=ehci.0,firstport=4' \
+  -device 'ich9-usb-ehci2,id=ehci-2,multifunction=on,bus=pcie.0,addr=0x1a.7' \
+  -device 'ich9-usb-uhci4,id=uhci-4,multifunction=on,bus=pcie.0,addr=0x1a,masterbus=ehci-2.0,firstport=0' \
+  -device 'ich9-usb-uhci5,id=uhci-5,multifunction=on,bus=pcie.0,addr=0x1a.1,masterbus=ehci-2.0,firstport=2' \
+  -device 'ich9-usb-uhci6,id=uhci-6,multifunction=on,bus=pcie.0,addr=0x1a.2,masterbus=ehci-2.0,firstport=4' \
+  -device 'ich9-intel-hda,id=audio0,bus=pcie.0,addr=0x1b' \
+  -device 'pcie-root-port,id=ich9-pcie-port-1,addr=1c.0,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=1,chassis=1' \
+  -device 'pcie-root-port,id=ich9-pcie-port-2,addr=1c.1,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=2,chassis=2' \
+  -device 'pcie-root-port,id=ich9-pcie-port-3,addr=1c.2,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=3,chassis=3' \
+  -device 'pcie-root-port,id=ich9-pcie-port-4,addr=1c.3,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=4,chassis=4' \
+  -device 'i82801b11-bridge,id=pcidmi,bus=pcie.0,addr=0x1e' \
+  -device 'pci-bridge,id=pci.0,chassis_nr=1,bus=pcidmi,addr=0x1' \
+  -device 'pci-bridge,id=pci.1,chassis_nr=2,bus=pcidmi,addr=0x2' \
+  -device 'pci-bridge,id=pci.2,chassis_nr=3,bus=pcidmi,addr=0x3' \
+  -device 'pci-bridge,id=pci.3,chassis_nr=4,bus=pcidmi,addr=0x4' \
   -device 'pci-bridge,id=pci.4,chassis_nr=4,bus=pci.1,addr=0x1c' \
   -device 'vmgenid,guid=54d1c06c-8f5b-440f-b5b2-6eab1380e13d' \
   -device 'usb-tablet,id=tablet,bus=ehci.0,port=1' \
@@ -36,4 +53,4 @@
   -iscsi 'initiator-name=iqn.1993-08.org.debian:01:aabbccddeeff' \
   -netdev 'type=tap,id=net0,ifname=tap8006i0,script=/usr/libexec/qemu-server/pve-bridge,downscript=/usr/libexec/qemu-server/pve-bridgedown,vhost=on' \
   -device 'virtio-net-pci,mac=2E:01:68:F9:9C:87,netdev=net0,bus=pci.0,addr=0x12,id=net0,rx_queue_size=1024,tx_queue_size=256,bootindex=300,host_mtu=1500,host_tunnel=off' \
-  -machine 'pflash0=pflash0,pflash1=drive-efidisk0,hpet=off,type=q35+pve0'
+  -machine 'pflash0=pflash0,pflash1=drive-efidisk0,hpet=off,type=q35+pve1'
diff --git a/src/test/cfg2cmd/q35-linux-hostpci.conf.cmd b/src/test/cfg2cmd/q35-linux-hostpci.conf.cmd
index d64264ec..ac7b8720 100644
--- a/src/test/cfg2cmd/q35-linux-hostpci.conf.cmd
+++ b/src/test/cfg2cmd/q35-linux-hostpci.conf.cmd
@@ -25,7 +25,24 @@
   -numa 'node,nodeid=1,cpus=1,memdev=ram-node1' \
   -global 'ICH9-LPC.disable_s3=1' \
   -global 'ICH9-LPC.disable_s4=1' \
-  -readconfig /usr/share/qemu-server/pve-q35-4.0.cfg \
+  -device 'ich9-usb-ehci1,id=ehci,multifunction=on,bus=pcie.0,addr=0x1d.7' \
+  -device 'ich9-usb-uhci1,id=uhci-1,multifunction=on,bus=pcie.0,addr=0x1d,masterbus=ehci.0,firstport=0' \
+  -device 'ich9-usb-uhci2,id=uhci-2,multifunction=on,bus=pcie.0,addr=0x1d.1,masterbus=ehci.0,firstport=2' \
+  -device 'ich9-usb-uhci3,id=uhci-3,multifunction=on,bus=pcie.0,addr=0x1d.2,masterbus=ehci.0,firstport=4' \
+  -device 'ich9-usb-ehci2,id=ehci-2,multifunction=on,bus=pcie.0,addr=0x1a.7' \
+  -device 'ich9-usb-uhci4,id=uhci-4,multifunction=on,bus=pcie.0,addr=0x1a,masterbus=ehci-2.0,firstport=0' \
+  -device 'ich9-usb-uhci5,id=uhci-5,multifunction=on,bus=pcie.0,addr=0x1a.1,masterbus=ehci-2.0,firstport=2' \
+  -device 'ich9-usb-uhci6,id=uhci-6,multifunction=on,bus=pcie.0,addr=0x1a.2,masterbus=ehci-2.0,firstport=4' \
+  -device 'ich9-intel-hda,id=audio0,bus=pcie.0,addr=0x1b' \
+  -device 'pcie-root-port,id=ich9-pcie-port-1,addr=1c.0,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=1,chassis=1' \
+  -device 'pcie-root-port,id=ich9-pcie-port-2,addr=1c.1,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=2,chassis=2' \
+  -device 'pcie-root-port,id=ich9-pcie-port-3,addr=1c.2,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=3,chassis=3' \
+  -device 'pcie-root-port,id=ich9-pcie-port-4,addr=1c.3,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=4,chassis=4' \
+  -device 'i82801b11-bridge,id=pcidmi,bus=pcie.0,addr=0x1e' \
+  -device 'pci-bridge,id=pci.0,chassis_nr=1,bus=pcidmi,addr=0x1' \
+  -device 'pci-bridge,id=pci.1,chassis_nr=2,bus=pcidmi,addr=0x2' \
+  -device 'pci-bridge,id=pci.2,chassis_nr=3,bus=pcidmi,addr=0x3' \
+  -device 'pci-bridge,id=pci.3,chassis_nr=4,bus=pcidmi,addr=0x4' \
   -device 'pci-bridge,id=pci.4,chassis_nr=4,bus=pci.1,addr=0x1c' \
   -device 'pcie-root-port,id=ich9-pcie-port-5,addr=10.0,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=5,chassis=5' \
   -device 'pcie-root-port,id=ich9-pcie-port-8,addr=10.3,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=8,chassis=8' \
@@ -42,4 +59,4 @@
   -iscsi 'initiator-name=iqn.1993-08.org.debian:01:aabbccddeeff' \
   -netdev 'type=tap,id=net0,ifname=tap8006i0,script=/usr/libexec/qemu-server/pve-bridge,downscript=/usr/libexec/qemu-server/pve-bridgedown,vhost=on' \
   -device 'virtio-net-pci,mac=2E:01:68:F9:9C:87,netdev=net0,bus=pci.0,addr=0x12,id=net0,rx_queue_size=1024,tx_queue_size=256,bootindex=300,host_mtu=1500,host_tunnel=off' \
-  -machine 'pflash0=pflash0,pflash1=drive-efidisk0,hpet=off,type=q35+pve0'
+  -machine 'pflash0=pflash0,pflash1=drive-efidisk0,hpet=off,type=q35+pve1'
diff --git a/src/test/cfg2cmd/q35-linux-ivshmem.conf.cmd b/src/test/cfg2cmd/q35-linux-ivshmem.conf.cmd
index 11ebc6bf..ca3626a0 100644
--- a/src/test/cfg2cmd/q35-linux-ivshmem.conf.cmd
+++ b/src/test/cfg2cmd/q35-linux-ivshmem.conf.cmd
@@ -22,7 +22,24 @@
   -numa 'node,nodeid=1,cpus=1,memdev=ram-node1' \
   -global 'ICH9-LPC.disable_s3=1' \
   -global 'ICH9-LPC.disable_s4=1' \
-  -readconfig /usr/share/qemu-server/pve-q35-4.0.cfg \
+  -device 'ich9-usb-ehci1,id=ehci,multifunction=on,bus=pcie.0,addr=0x1d.7' \
+  -device 'ich9-usb-uhci1,id=uhci-1,multifunction=on,bus=pcie.0,addr=0x1d,masterbus=ehci.0,firstport=0' \
+  -device 'ich9-usb-uhci2,id=uhci-2,multifunction=on,bus=pcie.0,addr=0x1d.1,masterbus=ehci.0,firstport=2' \
+  -device 'ich9-usb-uhci3,id=uhci-3,multifunction=on,bus=pcie.0,addr=0x1d.2,masterbus=ehci.0,firstport=4' \
+  -device 'ich9-usb-ehci2,id=ehci-2,multifunction=on,bus=pcie.0,addr=0x1a.7' \
+  -device 'ich9-usb-uhci4,id=uhci-4,multifunction=on,bus=pcie.0,addr=0x1a,masterbus=ehci-2.0,firstport=0' \
+  -device 'ich9-usb-uhci5,id=uhci-5,multifunction=on,bus=pcie.0,addr=0x1a.1,masterbus=ehci-2.0,firstport=2' \
+  -device 'ich9-usb-uhci6,id=uhci-6,multifunction=on,bus=pcie.0,addr=0x1a.2,masterbus=ehci-2.0,firstport=4' \
+  -device 'ich9-intel-hda,id=audio0,bus=pcie.0,addr=0x1b' \
+  -device 'pcie-root-port,id=ich9-pcie-port-1,addr=1c.0,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=1,chassis=1' \
+  -device 'pcie-root-port,id=ich9-pcie-port-2,addr=1c.1,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=2,chassis=2' \
+  -device 'pcie-root-port,id=ich9-pcie-port-3,addr=1c.2,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=3,chassis=3' \
+  -device 'pcie-root-port,id=ich9-pcie-port-4,addr=1c.3,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=4,chassis=4' \
+  -device 'i82801b11-bridge,id=pcidmi,bus=pcie.0,addr=0x1e' \
+  -device 'pci-bridge,id=pci.0,chassis_nr=1,bus=pcidmi,addr=0x1' \
+  -device 'pci-bridge,id=pci.1,chassis_nr=2,bus=pcidmi,addr=0x2' \
+  -device 'pci-bridge,id=pci.2,chassis_nr=3,bus=pcidmi,addr=0x3' \
+  -device 'pci-bridge,id=pci.3,chassis_nr=4,bus=pcidmi,addr=0x4' \
   -device 'pci-bridge,id=pci.4,chassis_nr=4,bus=pci.1,addr=0x1c' \
   -device 'vmgenid,guid=54d1c06c-8f5b-440f-b5b2-6eab1380e13d' \
   -device 'usb-tablet,id=tablet,bus=ehci.0,port=1' \
@@ -33,4 +50,4 @@
   -device 'virtio-net-pci,mac=2E:01:68:F9:9C:87,netdev=net0,bus=pci.0,addr=0x12,id=net0,rx_queue_size=1024,tx_queue_size=256,bootindex=300,host_mtu=1500,host_tunnel=off' \
   -device 'ivshmem-plain,memdev=ivshmem,bus=pcie.0,addr=0x14,' \
   -object 'memory-backend-file,id=ivshmem,share=on,mem-path=/dev/shm/pve-shm-8006,size=512M' \
-  -machine 'hpet=off,type=q35+pve0'
+  -machine 'hpet=off,type=q35+pve1'
diff --git a/src/test/cfg2cmd/q35-scsihw2.conf.cmd b/src/test/cfg2cmd/q35-scsihw2.conf.cmd
index da1a251a..e2cced76 100644
--- a/src/test/cfg2cmd/q35-scsihw2.conf.cmd
+++ b/src/test/cfg2cmd/q35-scsihw2.conf.cmd
@@ -22,7 +22,24 @@
   -object '{"id":"throttle-drive-scsi15","limits":{},"qom-type":"throttle-group"}' \
   -global 'ICH9-LPC.disable_s3=1' \
   -global 'ICH9-LPC.disable_s4=1' \
-  -readconfig /usr/share/qemu-server/pve-q35-4.0.cfg \
+  -device 'ich9-usb-ehci1,id=ehci,multifunction=on,bus=pcie.0,addr=0x1d.7' \
+  -device 'ich9-usb-uhci1,id=uhci-1,multifunction=on,bus=pcie.0,addr=0x1d,masterbus=ehci.0,firstport=0' \
+  -device 'ich9-usb-uhci2,id=uhci-2,multifunction=on,bus=pcie.0,addr=0x1d.1,masterbus=ehci.0,firstport=2' \
+  -device 'ich9-usb-uhci3,id=uhci-3,multifunction=on,bus=pcie.0,addr=0x1d.2,masterbus=ehci.0,firstport=4' \
+  -device 'ich9-usb-ehci2,id=ehci-2,multifunction=on,bus=pcie.0,addr=0x1a.7' \
+  -device 'ich9-usb-uhci4,id=uhci-4,multifunction=on,bus=pcie.0,addr=0x1a,masterbus=ehci-2.0,firstport=0' \
+  -device 'ich9-usb-uhci5,id=uhci-5,multifunction=on,bus=pcie.0,addr=0x1a.1,masterbus=ehci-2.0,firstport=2' \
+  -device 'ich9-usb-uhci6,id=uhci-6,multifunction=on,bus=pcie.0,addr=0x1a.2,masterbus=ehci-2.0,firstport=4' \
+  -device 'ich9-intel-hda,id=audio0,bus=pcie.0,addr=0x1b' \
+  -device 'pcie-root-port,id=ich9-pcie-port-1,addr=1c.0,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=1,chassis=1' \
+  -device 'pcie-root-port,id=ich9-pcie-port-2,addr=1c.1,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=2,chassis=2' \
+  -device 'pcie-root-port,id=ich9-pcie-port-3,addr=1c.2,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=3,chassis=3' \
+  -device 'pcie-root-port,id=ich9-pcie-port-4,addr=1c.3,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=4,chassis=4' \
+  -device 'i82801b11-bridge,id=pcidmi,bus=pcie.0,addr=0x1e' \
+  -device 'pci-bridge,id=pci.0,chassis_nr=1,bus=pcidmi,addr=0x1' \
+  -device 'pci-bridge,id=pci.1,chassis_nr=2,bus=pcidmi,addr=0x2' \
+  -device 'pci-bridge,id=pci.2,chassis_nr=3,bus=pcidmi,addr=0x3' \
+  -device 'pci-bridge,id=pci.3,chassis_nr=4,bus=pcidmi,addr=0x4' \
   -device 'pci-bridge,id=pci.4,chassis_nr=4,bus=pci.1,addr=0x1c' \
   -device 'vmgenid,guid=54d1c06c-8f5b-440f-b5b2-6eab1380e13d' \
   -device 'usb-tablet,id=tablet,bus=ehci.0,port=1' \
@@ -34,4 +51,4 @@
   -device 'scsi-hd,bus=scsihw2.0,scsi-id=1,drive=drive-scsi15,id=scsi15,device_id=drive-scsi15,write-cache=on' \
   -netdev 'type=tap,id=net0,ifname=tap8006i0,script=/usr/libexec/qemu-server/pve-bridge,downscript=/usr/libexec/qemu-server/pve-bridgedown,vhost=on' \
   -device 'virtio-net-pci,mac=2E:01:68:F9:9C:87,netdev=net0,bus=pci.0,addr=0x12,id=net0,rx_queue_size=1024,tx_queue_size=256,bootindex=300,host_mtu=1500,host_tunnel=off' \
-  -machine 'pflash0=pflash0,pflash1=drive-efidisk0,hpet=off,type=q35+pve0'
+  -machine 'pflash0=pflash0,pflash1=drive-efidisk0,hpet=off,type=q35+pve1'
diff --git a/src/test/cfg2cmd/q35-simple.conf.cmd b/src/test/cfg2cmd/q35-simple.conf.cmd
index c01f9e98..75fc78d9 100644
--- a/src/test/cfg2cmd/q35-simple.conf.cmd
+++ b/src/test/cfg2cmd/q35-simple.conf.cmd
@@ -21,7 +21,24 @@
   -m 512 \
   -global 'ICH9-LPC.disable_s3=1' \
   -global 'ICH9-LPC.disable_s4=1' \
-  -readconfig /usr/share/qemu-server/pve-q35-4.0.cfg \
+  -device 'ich9-usb-ehci1,id=ehci,multifunction=on,bus=pcie.0,addr=0x1d.7' \
+  -device 'ich9-usb-uhci1,id=uhci-1,multifunction=on,bus=pcie.0,addr=0x1d,masterbus=ehci.0,firstport=0' \
+  -device 'ich9-usb-uhci2,id=uhci-2,multifunction=on,bus=pcie.0,addr=0x1d.1,masterbus=ehci.0,firstport=2' \
+  -device 'ich9-usb-uhci3,id=uhci-3,multifunction=on,bus=pcie.0,addr=0x1d.2,masterbus=ehci.0,firstport=4' \
+  -device 'ich9-usb-ehci2,id=ehci-2,multifunction=on,bus=pcie.0,addr=0x1a.7' \
+  -device 'ich9-usb-uhci4,id=uhci-4,multifunction=on,bus=pcie.0,addr=0x1a,masterbus=ehci-2.0,firstport=0' \
+  -device 'ich9-usb-uhci5,id=uhci-5,multifunction=on,bus=pcie.0,addr=0x1a.1,masterbus=ehci-2.0,firstport=2' \
+  -device 'ich9-usb-uhci6,id=uhci-6,multifunction=on,bus=pcie.0,addr=0x1a.2,masterbus=ehci-2.0,firstport=4' \
+  -device 'ich9-intel-hda,id=audio0,bus=pcie.0,addr=0x1b' \
+  -device 'pcie-root-port,id=ich9-pcie-port-1,addr=1c.0,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=1,chassis=1' \
+  -device 'pcie-root-port,id=ich9-pcie-port-2,addr=1c.1,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=2,chassis=2' \
+  -device 'pcie-root-port,id=ich9-pcie-port-3,addr=1c.2,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=3,chassis=3' \
+  -device 'pcie-root-port,id=ich9-pcie-port-4,addr=1c.3,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=4,chassis=4' \
+  -device 'i82801b11-bridge,id=pcidmi,bus=pcie.0,addr=0x1e' \
+  -device 'pci-bridge,id=pci.0,chassis_nr=1,bus=pcidmi,addr=0x1' \
+  -device 'pci-bridge,id=pci.1,chassis_nr=2,bus=pcidmi,addr=0x2' \
+  -device 'pci-bridge,id=pci.2,chassis_nr=3,bus=pcidmi,addr=0x3' \
+  -device 'pci-bridge,id=pci.3,chassis_nr=4,bus=pcidmi,addr=0x4' \
   -device 'pci-bridge,id=pci.4,chassis_nr=4,bus=pci.1,addr=0x1c' \
   -device 'vmgenid,guid=54d1c06c-8f5b-440f-b5b2-6eab1380e13d' \
   -device 'usb-tablet,id=tablet,bus=ehci.0,port=1' \
@@ -30,4 +47,4 @@
   -iscsi 'initiator-name=iqn.1993-08.org.debian:01:aabbccddeeff' \
   -netdev 'type=tap,id=net0,ifname=tap8006i0,script=/usr/libexec/qemu-server/pve-bridge,downscript=/usr/libexec/qemu-server/pve-bridgedown,vhost=on' \
   -device 'virtio-net-pci,mac=2E:01:68:F9:9C:87,netdev=net0,bus=pci.0,addr=0x12,id=net0,rx_queue_size=1024,tx_queue_size=256,bootindex=300,host_mtu=1500,host_tunnel=off' \
-  -machine 'pflash0=pflash0,pflash1=drive-efidisk0,hpet=off,type=q35+pve0'
+  -machine 'pflash0=pflash0,pflash1=drive-efidisk0,hpet=off,type=q35+pve1'
diff --git a/src/test/cfg2cmd/q35-viommu-intel-aw-bits.conf.cmd b/src/test/cfg2cmd/q35-viommu-intel-aw-bits.conf.cmd
index f7c44beb..8b00caba 100644
--- a/src/test/cfg2cmd/q35-viommu-intel-aw-bits.conf.cmd
+++ b/src/test/cfg2cmd/q35-viommu-intel-aw-bits.conf.cmd
@@ -17,10 +17,27 @@
   -global 'ICH9-LPC.disable_s3=1' \
   -global 'ICH9-LPC.disable_s4=1' \
   -device 'intel-iommu,intremap=on,caching-mode=on,aw-bits=39' \
-  -readconfig /usr/share/qemu-server/pve-q35-4.0.cfg \
+  -device 'ich9-usb-ehci1,id=ehci,multifunction=on,bus=pcie.0,addr=0x1d.7' \
+  -device 'ich9-usb-uhci1,id=uhci-1,multifunction=on,bus=pcie.0,addr=0x1d,masterbus=ehci.0,firstport=0' \
+  -device 'ich9-usb-uhci2,id=uhci-2,multifunction=on,bus=pcie.0,addr=0x1d.1,masterbus=ehci.0,firstport=2' \
+  -device 'ich9-usb-uhci3,id=uhci-3,multifunction=on,bus=pcie.0,addr=0x1d.2,masterbus=ehci.0,firstport=4' \
+  -device 'ich9-usb-ehci2,id=ehci-2,multifunction=on,bus=pcie.0,addr=0x1a.7' \
+  -device 'ich9-usb-uhci4,id=uhci-4,multifunction=on,bus=pcie.0,addr=0x1a,masterbus=ehci-2.0,firstport=0' \
+  -device 'ich9-usb-uhci5,id=uhci-5,multifunction=on,bus=pcie.0,addr=0x1a.1,masterbus=ehci-2.0,firstport=2' \
+  -device 'ich9-usb-uhci6,id=uhci-6,multifunction=on,bus=pcie.0,addr=0x1a.2,masterbus=ehci-2.0,firstport=4' \
+  -device 'ich9-intel-hda,id=audio0,bus=pcie.0,addr=0x1b' \
+  -device 'pcie-root-port,id=ich9-pcie-port-1,addr=1c.0,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=1,chassis=1' \
+  -device 'pcie-root-port,id=ich9-pcie-port-2,addr=1c.1,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=2,chassis=2' \
+  -device 'pcie-root-port,id=ich9-pcie-port-3,addr=1c.2,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=3,chassis=3' \
+  -device 'pcie-root-port,id=ich9-pcie-port-4,addr=1c.3,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=4,chassis=4' \
+  -device 'i82801b11-bridge,id=pcidmi,bus=pcie.0,addr=0x1e' \
+  -device 'pci-bridge,id=pci.0,chassis_nr=1,bus=pcidmi,addr=0x1' \
+  -device 'pci-bridge,id=pci.1,chassis_nr=2,bus=pcidmi,addr=0x2' \
+  -device 'pci-bridge,id=pci.2,chassis_nr=3,bus=pcidmi,addr=0x3' \
+  -device 'pci-bridge,id=pci.3,chassis_nr=4,bus=pcidmi,addr=0x4' \
   -device 'pci-bridge,id=pci.4,chassis_nr=4,bus=pci.1,addr=0x1c' \
   -device 'usb-tablet,id=tablet,bus=ehci.0,port=1' \
   -device 'VGA,id=vga,bus=pcie.0,addr=0x1' \
   -device 'virtio-balloon-pci,id=balloon0,bus=pci.0,addr=0x3,free-page-reporting=on' \
   -iscsi 'initiator-name=iqn.1993-08.org.debian:01:aabbccddeeff' \
-  -machine 'type=q35+pve0,kernel-irqchip=split'
+  -machine 'type=q35+pve1,kernel-irqchip=split'
diff --git a/src/test/cfg2cmd/q35-viommu-intel-guest-phys-bits.conf.cmd b/src/test/cfg2cmd/q35-viommu-intel-guest-phys-bits.conf.cmd
index dab8c958..f000d64d 100644
--- a/src/test/cfg2cmd/q35-viommu-intel-guest-phys-bits.conf.cmd
+++ b/src/test/cfg2cmd/q35-viommu-intel-guest-phys-bits.conf.cmd
@@ -17,10 +17,27 @@
   -global 'ICH9-LPC.disable_s3=1' \
   -global 'ICH9-LPC.disable_s4=1' \
   -device 'intel-iommu,intremap=on,caching-mode=on' \
-  -readconfig /usr/share/qemu-server/pve-q35-4.0.cfg \
+  -device 'ich9-usb-ehci1,id=ehci,multifunction=on,bus=pcie.0,addr=0x1d.7' \
+  -device 'ich9-usb-uhci1,id=uhci-1,multifunction=on,bus=pcie.0,addr=0x1d,masterbus=ehci.0,firstport=0' \
+  -device 'ich9-usb-uhci2,id=uhci-2,multifunction=on,bus=pcie.0,addr=0x1d.1,masterbus=ehci.0,firstport=2' \
+  -device 'ich9-usb-uhci3,id=uhci-3,multifunction=on,bus=pcie.0,addr=0x1d.2,masterbus=ehci.0,firstport=4' \
+  -device 'ich9-usb-ehci2,id=ehci-2,multifunction=on,bus=pcie.0,addr=0x1a.7' \
+  -device 'ich9-usb-uhci4,id=uhci-4,multifunction=on,bus=pcie.0,addr=0x1a,masterbus=ehci-2.0,firstport=0' \
+  -device 'ich9-usb-uhci5,id=uhci-5,multifunction=on,bus=pcie.0,addr=0x1a.1,masterbus=ehci-2.0,firstport=2' \
+  -device 'ich9-usb-uhci6,id=uhci-6,multifunction=on,bus=pcie.0,addr=0x1a.2,masterbus=ehci-2.0,firstport=4' \
+  -device 'ich9-intel-hda,id=audio0,bus=pcie.0,addr=0x1b' \
+  -device 'pcie-root-port,id=ich9-pcie-port-1,addr=1c.0,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=1,chassis=1' \
+  -device 'pcie-root-port,id=ich9-pcie-port-2,addr=1c.1,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=2,chassis=2' \
+  -device 'pcie-root-port,id=ich9-pcie-port-3,addr=1c.2,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=3,chassis=3' \
+  -device 'pcie-root-port,id=ich9-pcie-port-4,addr=1c.3,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=4,chassis=4' \
+  -device 'i82801b11-bridge,id=pcidmi,bus=pcie.0,addr=0x1e' \
+  -device 'pci-bridge,id=pci.0,chassis_nr=1,bus=pcidmi,addr=0x1' \
+  -device 'pci-bridge,id=pci.1,chassis_nr=2,bus=pcidmi,addr=0x2' \
+  -device 'pci-bridge,id=pci.2,chassis_nr=3,bus=pcidmi,addr=0x3' \
+  -device 'pci-bridge,id=pci.3,chassis_nr=4,bus=pcidmi,addr=0x4' \
   -device 'pci-bridge,id=pci.4,chassis_nr=4,bus=pci.1,addr=0x1c' \
   -device 'usb-tablet,id=tablet,bus=ehci.0,port=1' \
   -device 'VGA,id=vga,bus=pcie.0,addr=0x1' \
   -device 'virtio-balloon-pci,id=balloon0,bus=pci.0,addr=0x3,free-page-reporting=on' \
   -iscsi 'initiator-name=iqn.1993-08.org.debian:01:aabbccddeeff' \
-  -machine 'type=q35+pve0,kernel-irqchip=split'
+  -machine 'type=q35+pve1,kernel-irqchip=split'
diff --git a/src/test/cfg2cmd/q35-viommu-intel.conf.cmd b/src/test/cfg2cmd/q35-viommu-intel.conf.cmd
index fd77a3c5..0191389b 100644
--- a/src/test/cfg2cmd/q35-viommu-intel.conf.cmd
+++ b/src/test/cfg2cmd/q35-viommu-intel.conf.cmd
@@ -17,10 +17,27 @@
   -global 'ICH9-LPC.disable_s3=1' \
   -global 'ICH9-LPC.disable_s4=1' \
   -device 'intel-iommu,intremap=on,caching-mode=on' \
-  -readconfig /usr/share/qemu-server/pve-q35-4.0.cfg \
+  -device 'ich9-usb-ehci1,id=ehci,multifunction=on,bus=pcie.0,addr=0x1d.7' \
+  -device 'ich9-usb-uhci1,id=uhci-1,multifunction=on,bus=pcie.0,addr=0x1d,masterbus=ehci.0,firstport=0' \
+  -device 'ich9-usb-uhci2,id=uhci-2,multifunction=on,bus=pcie.0,addr=0x1d.1,masterbus=ehci.0,firstport=2' \
+  -device 'ich9-usb-uhci3,id=uhci-3,multifunction=on,bus=pcie.0,addr=0x1d.2,masterbus=ehci.0,firstport=4' \
+  -device 'ich9-usb-ehci2,id=ehci-2,multifunction=on,bus=pcie.0,addr=0x1a.7' \
+  -device 'ich9-usb-uhci4,id=uhci-4,multifunction=on,bus=pcie.0,addr=0x1a,masterbus=ehci-2.0,firstport=0' \
+  -device 'ich9-usb-uhci5,id=uhci-5,multifunction=on,bus=pcie.0,addr=0x1a.1,masterbus=ehci-2.0,firstport=2' \
+  -device 'ich9-usb-uhci6,id=uhci-6,multifunction=on,bus=pcie.0,addr=0x1a.2,masterbus=ehci-2.0,firstport=4' \
+  -device 'ich9-intel-hda,id=audio0,bus=pcie.0,addr=0x1b' \
+  -device 'pcie-root-port,id=ich9-pcie-port-1,addr=1c.0,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=1,chassis=1' \
+  -device 'pcie-root-port,id=ich9-pcie-port-2,addr=1c.1,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=2,chassis=2' \
+  -device 'pcie-root-port,id=ich9-pcie-port-3,addr=1c.2,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=3,chassis=3' \
+  -device 'pcie-root-port,id=ich9-pcie-port-4,addr=1c.3,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=4,chassis=4' \
+  -device 'i82801b11-bridge,id=pcidmi,bus=pcie.0,addr=0x1e' \
+  -device 'pci-bridge,id=pci.0,chassis_nr=1,bus=pcidmi,addr=0x1' \
+  -device 'pci-bridge,id=pci.1,chassis_nr=2,bus=pcidmi,addr=0x2' \
+  -device 'pci-bridge,id=pci.2,chassis_nr=3,bus=pcidmi,addr=0x3' \
+  -device 'pci-bridge,id=pci.3,chassis_nr=4,bus=pcidmi,addr=0x4' \
   -device 'pci-bridge,id=pci.4,chassis_nr=4,bus=pci.1,addr=0x1c' \
   -device 'usb-tablet,id=tablet,bus=ehci.0,port=1' \
   -device 'VGA,id=vga,bus=pcie.0,addr=0x1' \
   -device 'virtio-balloon-pci,id=balloon0,bus=pci.0,addr=0x3,free-page-reporting=on' \
   -iscsi 'initiator-name=iqn.1993-08.org.debian:01:aabbccddeeff' \
-  -machine 'type=q35+pve0,kernel-irqchip=split'
+  -machine 'type=q35+pve1,kernel-irqchip=split'
diff --git a/src/test/cfg2cmd/q35-viommu-virtio-aw-bits.conf.cmd b/src/test/cfg2cmd/q35-viommu-virtio-aw-bits.conf.cmd
index 51630e2c..1b079349 100644
--- a/src/test/cfg2cmd/q35-viommu-virtio-aw-bits.conf.cmd
+++ b/src/test/cfg2cmd/q35-viommu-virtio-aw-bits.conf.cmd
@@ -16,11 +16,28 @@
   -m 512 \
   -global 'ICH9-LPC.disable_s3=1' \
   -global 'ICH9-LPC.disable_s4=1' \
-  -readconfig /usr/share/qemu-server/pve-q35-4.0.cfg \
+  -device 'ich9-usb-ehci1,id=ehci,multifunction=on,bus=pcie.0,addr=0x1d.7' \
+  -device 'ich9-usb-uhci1,id=uhci-1,multifunction=on,bus=pcie.0,addr=0x1d,masterbus=ehci.0,firstport=0' \
+  -device 'ich9-usb-uhci2,id=uhci-2,multifunction=on,bus=pcie.0,addr=0x1d.1,masterbus=ehci.0,firstport=2' \
+  -device 'ich9-usb-uhci3,id=uhci-3,multifunction=on,bus=pcie.0,addr=0x1d.2,masterbus=ehci.0,firstport=4' \
+  -device 'ich9-usb-ehci2,id=ehci-2,multifunction=on,bus=pcie.0,addr=0x1a.7' \
+  -device 'ich9-usb-uhci4,id=uhci-4,multifunction=on,bus=pcie.0,addr=0x1a,masterbus=ehci-2.0,firstport=0' \
+  -device 'ich9-usb-uhci5,id=uhci-5,multifunction=on,bus=pcie.0,addr=0x1a.1,masterbus=ehci-2.0,firstport=2' \
+  -device 'ich9-usb-uhci6,id=uhci-6,multifunction=on,bus=pcie.0,addr=0x1a.2,masterbus=ehci-2.0,firstport=4' \
+  -device 'ich9-intel-hda,id=audio0,bus=pcie.0,addr=0x1b' \
+  -device 'pcie-root-port,id=ich9-pcie-port-1,addr=1c.0,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=1,chassis=1' \
+  -device 'pcie-root-port,id=ich9-pcie-port-2,addr=1c.1,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=2,chassis=2' \
+  -device 'pcie-root-port,id=ich9-pcie-port-3,addr=1c.2,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=3,chassis=3' \
+  -device 'pcie-root-port,id=ich9-pcie-port-4,addr=1c.3,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=4,chassis=4' \
+  -device 'i82801b11-bridge,id=pcidmi,bus=pcie.0,addr=0x1e' \
+  -device 'pci-bridge,id=pci.0,chassis_nr=1,bus=pcidmi,addr=0x1' \
+  -device 'pci-bridge,id=pci.1,chassis_nr=2,bus=pcidmi,addr=0x2' \
+  -device 'pci-bridge,id=pci.2,chassis_nr=3,bus=pcidmi,addr=0x3' \
+  -device 'pci-bridge,id=pci.3,chassis_nr=4,bus=pcidmi,addr=0x4' \
   -device 'pci-bridge,id=pci.4,chassis_nr=4,bus=pci.1,addr=0x1c' \
   -device 'usb-tablet,id=tablet,bus=ehci.0,port=1' \
   -device 'VGA,id=vga,bus=pcie.0,addr=0x1' \
   -device 'virtio-balloon-pci,id=balloon0,bus=pci.0,addr=0x3,free-page-reporting=on' \
   -iscsi 'initiator-name=iqn.1993-08.org.debian:01:aabbccddeeff' \
   -device 'virtio-iommu-pci,aw-bits=39' \
-  -machine 'type=q35+pve0'
+  -machine 'type=q35+pve1'
diff --git a/src/test/cfg2cmd/q35-viommu-virtio.conf.cmd b/src/test/cfg2cmd/q35-viommu-virtio.conf.cmd
index 400d3247..205fc7ee 100644
--- a/src/test/cfg2cmd/q35-viommu-virtio.conf.cmd
+++ b/src/test/cfg2cmd/q35-viommu-virtio.conf.cmd
@@ -16,11 +16,28 @@
   -m 512 \
   -global 'ICH9-LPC.disable_s3=1' \
   -global 'ICH9-LPC.disable_s4=1' \
-  -readconfig /usr/share/qemu-server/pve-q35-4.0.cfg \
+  -device 'ich9-usb-ehci1,id=ehci,multifunction=on,bus=pcie.0,addr=0x1d.7' \
+  -device 'ich9-usb-uhci1,id=uhci-1,multifunction=on,bus=pcie.0,addr=0x1d,masterbus=ehci.0,firstport=0' \
+  -device 'ich9-usb-uhci2,id=uhci-2,multifunction=on,bus=pcie.0,addr=0x1d.1,masterbus=ehci.0,firstport=2' \
+  -device 'ich9-usb-uhci3,id=uhci-3,multifunction=on,bus=pcie.0,addr=0x1d.2,masterbus=ehci.0,firstport=4' \
+  -device 'ich9-usb-ehci2,id=ehci-2,multifunction=on,bus=pcie.0,addr=0x1a.7' \
+  -device 'ich9-usb-uhci4,id=uhci-4,multifunction=on,bus=pcie.0,addr=0x1a,masterbus=ehci-2.0,firstport=0' \
+  -device 'ich9-usb-uhci5,id=uhci-5,multifunction=on,bus=pcie.0,addr=0x1a.1,masterbus=ehci-2.0,firstport=2' \
+  -device 'ich9-usb-uhci6,id=uhci-6,multifunction=on,bus=pcie.0,addr=0x1a.2,masterbus=ehci-2.0,firstport=4' \
+  -device 'ich9-intel-hda,id=audio0,bus=pcie.0,addr=0x1b' \
+  -device 'pcie-root-port,id=ich9-pcie-port-1,addr=1c.0,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=1,chassis=1' \
+  -device 'pcie-root-port,id=ich9-pcie-port-2,addr=1c.1,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=2,chassis=2' \
+  -device 'pcie-root-port,id=ich9-pcie-port-3,addr=1c.2,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=3,chassis=3' \
+  -device 'pcie-root-port,id=ich9-pcie-port-4,addr=1c.3,x-speed=16,x-width=32,multifunction=on,bus=pcie.0,port=4,chassis=4' \
+  -device 'i82801b11-bridge,id=pcidmi,bus=pcie.0,addr=0x1e' \
+  -device 'pci-bridge,id=pci.0,chassis_nr=1,bus=pcidmi,addr=0x1' \
+  -device 'pci-bridge,id=pci.1,chassis_nr=2,bus=pcidmi,addr=0x2' \
+  -device 'pci-bridge,id=pci.2,chassis_nr=3,bus=pcidmi,addr=0x3' \
+  -device 'pci-bridge,id=pci.3,chassis_nr=4,bus=pcidmi,addr=0x4' \
   -device 'pci-bridge,id=pci.4,chassis_nr=4,bus=pci.1,addr=0x1c' \
   -device 'usb-tablet,id=tablet,bus=ehci.0,port=1' \
   -device 'VGA,id=vga,bus=pcie.0,addr=0x1' \
   -device 'virtio-balloon-pci,id=balloon0,bus=pci.0,addr=0x3,free-page-reporting=on' \
   -iscsi 'initiator-name=iqn.1993-08.org.debian:01:aabbccddeeff' \
   -device virtio-iommu-pci \
-  -machine 'type=q35+pve0'
+  -machine 'type=q35+pve1'
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 12+ messages in thread

* [PATCH qemu-server v2 9/9] machine: add 'pci-layout' option
  2026-10-15  8:10 [RFC proxmox-perl-rs/qemu-server/qemu-server-rs v2 0/9] pci-handling rewrite (part 1/2/3) Dominik Csapak
                   ` (7 preceding siblings ...)
  2026-10-15  8:10 ` [PATCH qemu-server v2 8/9] cfg2cmd: q35: generate chipset devices with machine version 11.1+pve1 Dominik Csapak
@ 2026-10-15  8:10 ` Dominik Csapak
  8 siblings, 0 replies; 12+ messages in thread
From: Dominik Csapak @ 2026-10-15  8:10 UTC (permalink / raw)
  To: pve-devel

This allows choosing the new 'grouped' pci layout, which gives every
device kind its own set of bridges. With that, the address of a device
only depends on its own index, so we can raise the per-kind limits
later without moving existing devices.

Since the layout is part of the guest ABI, changing it changes the
addresses of all devices, so it's only intended for new guests.

Guests with the grouped layout always get a qemu-xhci controller,
which is also used for the tablet, since the layout does not include
the EHCI and UHCI controllers. It puts them behind an usb hub there.
The xhci controller is never unplugged there.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 src/PVE/QemuServer.pm                         | 23 ++++++---
 src/PVE/QemuServer/Machine.pm                 | 10 ++++
 src/PVE/QemuServer/PCI.pm                     |  3 +-
 src/PVE/QemuServer/USB.pm                     | 26 ++++++++--
 src/PVE/QemuServer/Virtiofs.pm                |  7 ++-
 src/test/TestsCommon/CommandLineMocks.pm      | 24 ++++++++++
 src/test/cfg2cmd/q35-grouped-layout.conf      | 17 +++++++
 src/test/cfg2cmd/q35-grouped-layout.conf.cmd  | 47 ++++++++++++++++++
 .../cfg2cmd/q35-grouped-virtiofs-viommu.conf  | 14 ++++++
 .../q35-grouped-virtiofs-viommu.conf.cmd      | 46 ++++++++++++++++++
 .../simple-grouped-virtiofs-viommu.conf       | 16 +++++++
 .../simple-grouped-virtiofs-viommu.conf.cmd   | 45 +++++++++++++++++
 src/test/cfg2cmd/simple-grouped.conf          | 15 ++++++
 src/test/cfg2cmd/simple-grouped.conf.cmd      | 41 ++++++++++++++++
 src/test/cfg2cmd/spice-win-grouped.conf       | 14 ++++++
 src/test/cfg2cmd/spice-win-grouped.conf.cmd   | 48 +++++++++++++++++++
 src/test/cfg2cmd/virtiofs-viommu.conf         | 16 +++++++
 src/test/cfg2cmd/virtiofs-viommu.conf.cmd     | 40 ++++++++++++++++
 18 files changed, 440 insertions(+), 12 deletions(-)
 create mode 100644 src/test/cfg2cmd/q35-grouped-layout.conf
 create mode 100644 src/test/cfg2cmd/q35-grouped-layout.conf.cmd
 create mode 100644 src/test/cfg2cmd/q35-grouped-virtiofs-viommu.conf
 create mode 100644 src/test/cfg2cmd/q35-grouped-virtiofs-viommu.conf.cmd
 create mode 100644 src/test/cfg2cmd/simple-grouped-virtiofs-viommu.conf
 create mode 100644 src/test/cfg2cmd/simple-grouped-virtiofs-viommu.conf.cmd
 create mode 100644 src/test/cfg2cmd/simple-grouped.conf
 create mode 100644 src/test/cfg2cmd/simple-grouped.conf.cmd
 create mode 100644 src/test/cfg2cmd/spice-win-grouped.conf
 create mode 100644 src/test/cfg2cmd/spice-win-grouped.conf.cmd
 create mode 100644 src/test/cfg2cmd/virtiofs-viommu.conf
 create mode 100644 src/test/cfg2cmd/virtiofs-viommu.conf.cmd

diff --git a/src/PVE/QemuServer.pm b/src/PVE/QemuServer.pm
index fe16e44b..3ad2e080 100644
--- a/src/PVE/QemuServer.pm
+++ b/src/PVE/QemuServer.pm
@@ -1183,8 +1183,9 @@ sub print_tabletdevice_full {
     my ($hw_config) = @_;
 
     my $usbbus = PVE::QemuServer::USB::get_default_usb_bus($hw_config);
+    my $port = $hw_config->layout() ne 'legacy' ? '15.7' : '1';
 
-    return "usb-tablet,id=tablet,bus=$usbbus.0,port=1";
+    return "usb-tablet,id=tablet,bus=$usbbus.0,port=$port";
 }
 
 sub print_keyboarddevice_full {
@@ -1192,7 +1193,10 @@ sub print_keyboarddevice_full {
 
     return if $hw_config->arch() ne 'aarch64';
 
-    return "usb-kbd,id=keyboard,bus=ehci.0,port=2";
+    my $usbbus = PVE::QemuServer::USB::get_default_usb_bus($hw_config);
+    my $port = $hw_config->layout() ne 'legacy' ? '15.8' : '2';
+
+    return "usb-kbd,id=keyboard,bus=$usbbus.0,port=$port";
 }
 
 sub print_drive_commandline_full {
@@ -1493,6 +1497,7 @@ sub print_vga_device {
     my $vgamem_mb = $vga->{memory};
 
     my $max_outputs = '';
+    my $multifunction = '';
     if ($qxlnum) {
         $type = $id ? 'qxl' : 'qxl-vga';
 
@@ -1502,6 +1507,10 @@ sub print_vga_device {
                 $max_outputs = ",max_outputs=4";
             }
         }
+
+        if (!$id && $hw_config->windows_version() && $hw_config->layout() ne 'legacy') {
+            $multifunction = ",multifunction=on";
+        }
     }
 
     die "no device-type for $vga->{type}\n" if !$type;
@@ -1545,7 +1554,7 @@ sub print_vga_device {
             if !PVE::Tools::dir_glob_regex('/dev/dri/', "renderD.*");
     }
 
-    return "$type,id=${vgaid}${memory}${max_outputs}${pciaddr}${edidoff}";
+    return "$type,id=${vgaid}${memory}${max_outputs}${pciaddr}${edidoff}${multifunction}";
 }
 
 sub vm_is_volid_owner {
@@ -3743,7 +3752,9 @@ sub config_to_command {
             unshift @$devices, '-device', "intel-iommu,intremap=on,caching-mode=on$aw_bits";
             push @$machineFlags, 'kernel-irqchip=split';
         } elsif ($viommu eq 'virtio') {
-            push @$devices, '-device', "virtio-iommu-pci$aw_bits";
+            # some layouts don't produce an address for it
+            my $pciaddr = $hw_config->addr('viommu') // '';
+            push @$devices, '-device', "virtio-iommu-pci${aw_bits}${pciaddr}";
         }
     }
 
@@ -3759,7 +3770,7 @@ sub config_to_command {
 
     push @$machineFlags, 'mem-merge=off' if defined($conf->{'allow-ksm'}) && !$conf->{'allow-ksm'};
 
-    PVE::QemuServer::Virtiofs::config($conf, $vmid, $devices);
+    PVE::QemuServer::Virtiofs::config($conf, $vmid, $hw_config, $devices);
 
     push @$cmd, @$devices;
     push @$cmd, '-rtc', join(',', @$rtcFlags) if scalar(@$rtcFlags);
@@ -4876,7 +4887,7 @@ sub vmconfig_hotplug_pending {
             $has_usb = 1;
             last;
         }
-        if (!$has_usb) {
+        if (!$has_usb && $hw_config->()->layout() eq 'legacy') {
             vm_deviceunplug($vmid, $conf, 'xhci');
         }
     }
diff --git a/src/PVE/QemuServer/Machine.pm b/src/PVE/QemuServer/Machine.pm
index e60377b2..041024c5 100644
--- a/src/PVE/QemuServer/Machine.pm
+++ b/src/PVE/QemuServer/Machine.pm
@@ -99,6 +99,16 @@ my $machine_fmt = {
             "Enables S4 power state. Defaults to false beginning with machine types 9.2+pve1, true before.",
         optional => 1,
     },
+    'pci-layout' => {
+        type => 'string',
+        optional => 1,
+        default => 'legacy',
+        enum => [qw(legacy grouped)],
+        description =>
+            "The PCI(e) address layout of the guest. 'grouped' gives every device kind"
+            . " its own set of bridges. Changing it changes the addresses of all devices. This"
+            . " option is experimental.",
+    },
 };
 
 PVE::JSONSchema::register_format('pve-qemu-machine-fmt', $machine_fmt);
diff --git a/src/PVE/QemuServer/PCI.pm b/src/PVE/QemuServer/PCI.pm
index 6b4eb179..b35d7177 100644
--- a/src/PVE/QemuServer/PCI.pm
+++ b/src/PVE/QemuServer/PCI.pm
@@ -622,6 +622,7 @@ sub hardware_config {
         push @$hostpcie, $i if $res->{pcie};
     }
 
+    my $machine_conf = PVE::QemuServer::Machine::parse_machine($conf->{machine});
     my $machine_type = PVE::QemuServer::Machine::get_vm_machine($conf, $forcemachine);
     my ($major, $minor, $patch, $pve) = PVE::QemuServer::Helpers::version_parts($machine_version);
 
@@ -635,7 +636,7 @@ sub hardware_config {
         $major,
         $minor,
         $pve,
-        'legacy',
+        $machine_conf->{'pci-layout'} // 'legacy',
         $hostpcie,
     );
 }
diff --git a/src/PVE/QemuServer/USB.pm b/src/PVE/QemuServer/USB.pm
index 42b4d3a3..b0024b07 100644
--- a/src/PVE/QemuServer/USB.pm
+++ b/src/PVE/QemuServer/USB.pm
@@ -121,13 +121,32 @@ my sub assert_usb_index_is_useable {
 sub get_default_usb_bus {
     my ($hw_config) = @_;
 
-    # we use uhci for old VMs because tablet driver was buggy in older qemu
-    return $hw_config->machine_type() eq 'i440fx' ? 'uhci' : 'ehci';
+    my $layout = $hw_config->layout();
+    if ($layout eq 'legacy') {
+        if ($hw_config->machine_type() eq 'i440fx') {
+            # we use uhci for old VMs because tablet driver was buggy in older qemu
+            return 'uhci';
+        } else {
+            return 'ehci';
+        }
+    } else {
+        return 'xhci';
+    }
 }
 
 sub get_usb_controllers {
     my ($conf, $hw_config) = @_;
 
+    if ($hw_config->layout() ne 'legacy') {
+        my $addr = $hw_config->addr('xhci');
+        return (
+            "-device", print_qemu_xhci_controller($addr),
+            # ports 1-14 are reserved for usbX, qemu-xhci has only 15 ports so
+            # use a hub for tablet and keyboard
+            "-device", "usb-hub,id=hub,bus=xhci.0,port=15",
+        );
+    }
+
     my $devices = [];
     my $pciaddr = "";
 
@@ -183,7 +202,8 @@ sub get_usb_devices {
     my $use_qemu_xhci =
         $hw_config->version_at_least(7, 1)
         && defined($ostype)
-        && ($ostype eq 'l26' || windows_version($ostype) > 7);
+        && ($ostype eq 'l26' || windows_version($ostype) > 7)
+        || $hw_config->layout() ne 'legacy';
 
     for (my $i = 0; $i < $MAX_USB_DEVICES; $i++) {
         my $devname = "usb$i";
diff --git a/src/PVE/QemuServer/Virtiofs.pm b/src/PVE/QemuServer/Virtiofs.pm
index b181db5f..32993d65 100644
--- a/src/PVE/QemuServer/Virtiofs.pm
+++ b/src/PVE/QemuServer/Virtiofs.pm
@@ -92,7 +92,7 @@ sub assert_virtiofs_config {
 }
 
 sub config {
-    my ($conf, $vmid, $devices) = @_;
+    my ($conf, $vmid, $hw_config, $devices) = @_;
 
     for (my $i = 0; $i < max_virtiofs(); $i++) {
         my $opt = "virtiofs$i";
@@ -104,12 +104,15 @@ sub config {
 
         push @$devices, '-chardev', "socket,id=virtiofs$i,path=$socket_path_root/vm$vmid-fs$i";
 
+        # some layouts don't produce an address for it
+        my $pciaddr = $hw_config->addr($opt) // '';
+
         # queue-size is set 1024 because of bug with Windows guests:
         # https://bugzilla.redhat.com/show_bug.cgi?id=1873088
         # 1024 is also always used in the virtiofs documentations:
         # https://gitlab.com/virtio-fs/virtiofsd#examples
         push @$devices, '-device',
-            'vhost-user-fs-pci,queue-size=1024' . ",chardev=virtiofs$i,tag=$virtiofs->{dirid}";
+            "vhost-user-fs-pci,queue-size=1024,chardev=virtiofs$i,tag=$virtiofs->{dirid}${pciaddr}";
     }
 }
 
diff --git a/src/test/TestsCommon/CommandLineMocks.pm b/src/test/TestsCommon/CommandLineMocks.pm
index a4b91136..c2e2f918 100644
--- a/src/test/TestsCommon/CommandLineMocks.pm
+++ b/src/test/TestsCommon/CommandLineMocks.pm
@@ -187,6 +187,19 @@ my $pci_map_config = {
 
 my $usb_map_config = {};
 
+my $dir_map_config = {
+    ids => {
+        share1 => {
+            type => 'dir',
+            map => ['node=localhost,path=/mnt/share1'],
+        },
+        share2 => {
+            type => 'dir',
+            map => ['node=localhost,path=/mnt/share2'],
+        },
+    },
+};
+
 my $cpu_hw_capabilities = {
     # Gathered from an AMD EPYC 9475F running kernel 6.11.11-2-pve
     'amd-turin-9005' =>
@@ -527,6 +540,17 @@ $mapping_pci_module->mock(
     },
 );
 
+our $mapping_dir_module = Test::MockModule->new("PVE::Mapping::Dir");
+$mapping_dir_module->mock(
+    config => sub {
+        return $dir_map_config;
+    },
+    # the mapped paths do not exist on the test host
+    assert_valid => sub {
+        return;
+    },
+);
+
 our $pci_module = Test::MockModule->new("PVE::QemuServer::PCI");
 $pci_module->mock(
     reserve_pci_usage => sub {
diff --git a/src/test/cfg2cmd/q35-grouped-layout.conf b/src/test/cfg2cmd/q35-grouped-layout.conf
new file mode 100644
index 00000000..e11b0414
--- /dev/null
+++ b/src/test/cfg2cmd/q35-grouped-layout.conf
@@ -0,0 +1,17 @@
+# TEST: Config with q35, Linux, SPICE, USB & grouped pci-layout
+#
+bios: ovmf
+bootdisk: scsi0
+cores: 2
+efidisk0: local:100/vm-100-disk-1.qcow2,size=128K
+machine: q35,pci-layout=grouped
+memory: 512
+net0: virtio=2E:01:68:F9:9C:87,bridge=vmbr0
+ostype: l26
+scsihw: virtio-scsi-pci
+smbios1: uuid=3dd750ce-d910-44d0-9493-525c0be4e687
+usb0: spice
+usb1: host=1234:5678
+usb2: host=1-2,usb3=1
+vga: qxl
+vmgenid: 54d1c06c-8f5b-440f-b5b2-6eab1380e13d
diff --git a/src/test/cfg2cmd/q35-grouped-layout.conf.cmd b/src/test/cfg2cmd/q35-grouped-layout.conf.cmd
new file mode 100644
index 00000000..2cac0f3f
--- /dev/null
+++ b/src/test/cfg2cmd/q35-grouped-layout.conf.cmd
@@ -0,0 +1,47 @@
+/usr/bin/kvm \
+  -id 8006 \
+  -name vm8006 \
+  -no-shutdown \
+  -chardev 'socket,id=qmp,path=/var/run/qemu-server/8006.qmp,server=on,wait=off' \
+  -object 'monitor-qmp,chardev=qmp,id=monitor-qmp' \
+  -chardev 'socket,id=qmp-event,path=/var/run/qmeventd.sock,reconnect-ms=5000' \
+  -object 'monitor-qmp,chardev=qmp-event,id=monitor-qmp-event' \
+  -pidfile /var/run/qemu-server/8006.pid \
+  -daemonize \
+  -smbios 'type=1,uuid=3dd750ce-d910-44d0-9493-525c0be4e687' \
+  -object '{"id":"throttle-drive-efidisk0","limits":{},"qom-type":"throttle-group"}' \
+  -blockdev '{"driver":"raw","file":{"driver":"file","filename":"/usr/share/pve-edk2-firmware//OVMF_CODE.fd"},"node-name":"pflash0","read-only":true}' \
+  -blockdev '{"detect-zeroes":"on","discard":"ignore","driver":"throttle","file":{"cache":{"direct":false,"no-flush":false},"detect-zeroes":"on","discard":"ignore","driver":"qcow2","file":{"aio":"io_uring","cache":{"direct":false,"no-flush":false},"detect-zeroes":"on","discard":"ignore","driver":"file","filename":"/var/lib/vz/images/100/vm-100-disk-1.qcow2","node-name":"e70e3017c5a79fdee5a04aa92ac1e9c","read-only":false},"node-name":"f70e3017c5a79fdee5a04aa92ac1e9c","read-only":false},"node-name":"drive-efidisk0","read-only":false,"throttle-group":"throttle-drive-efidisk0"}' \
+  -global 'ICH9-LPC.acpi-pci-hotplug-with-bridge-support=off' \
+  -smp '2,sockets=1,cores=2,maxcpus=2' \
+  -nodefaults \
+  -boot 'menu=on,strict=on,reboot-timeout=1000,splash=/usr/share/qemu-server/bootsplash.jpg' \
+  -vnc 'unix:/var/run/qemu-server/8006.vnc,password=on' \
+  -cpu kvm64,enforce,+kvm_pv_eoi,+kvm_pv_unhalt,+lahf_lm,+sep \
+  -m 512 \
+  -global 'ICH9-LPC.disable_s3=1' \
+  -global 'ICH9-LPC.disable_s4=1' \
+  -device 'pcie-pci-bridge,id=pve.sys0,bus=pcie.0,addr=0x4,multifunction=on' \
+  -device 'pcie-pci-bridge,id=pve.virtio-fs0,bus=pcie.0,addr=0x5,multifunction=on' \
+  -device 'pcie-pci-bridge,id=pve.net0,bus=pcie.0,addr=0x6,multifunction=on' \
+  -device 'pcie-pci-bridge,id=pve.net1,bus=pcie.0,addr=0x6.1' \
+  -device 'pcie-pci-bridge,id=pve.scsi0,bus=pcie.0,addr=0x7,multifunction=on' \
+  -device 'pcie-pci-bridge,id=pve.virtio-blk0,bus=pcie.0,addr=0x8,multifunction=on' \
+  -device 'pcie-pci-bridge,id=pve.hostpci0,bus=pcie.0,addr=0x9,multifunction=on' \
+  -device 'vmgenid,guid=54d1c06c-8f5b-440f-b5b2-6eab1380e13d' \
+  -device 'qemu-xhci,p2=15,p3=15,id=xhci,bus=pve.sys0,addr=0x3' \
+  -device 'usb-hub,id=hub,bus=xhci.0,port=15' \
+  -chardev 'spicevmc,id=usbredirchardev0,name=usbredir' \
+  -device 'usb-redir,chardev=usbredirchardev0,id=usbredirdev0,bus=xhci.0,port=1' \
+  -device 'usb-host,bus=xhci.0,port=2,vendorid=0x1234,productid=0x5678,id=usb1' \
+  -device 'usb-host,bus=xhci.0,port=3,hostbus=1,hostport=2,id=usb2' \
+  -device 'qxl-vga,id=vga,max_outputs=4,bus=pcie.0,addr=0x2' \
+  -device 'virtio-serial,id=spice,bus=pve.sys0,addr=0x5' \
+  -chardev 'spicevmc,id=vdagent,name=vdagent' \
+  -device 'virtserialport,chardev=vdagent,name=com.redhat.spice.0' \
+  -spice 'tls-port=61000,addr=127.0.0.1,tls-ciphers=HIGH,seamless-migration=on' \
+  -device 'virtio-balloon-pci,id=balloon0,bus=pve.sys0,addr=0x2,free-page-reporting=on' \
+  -iscsi 'initiator-name=iqn.1993-08.org.debian:01:aabbccddeeff' \
+  -netdev 'type=tap,id=net0,ifname=tap8006i0,script=/usr/libexec/qemu-server/pve-bridge,downscript=/usr/libexec/qemu-server/pve-bridgedown,vhost=on' \
+  -device 'virtio-net-pci,mac=2E:01:68:F9:9C:87,netdev=net0,bus=pve.net0,addr=0x1,id=net0,rx_queue_size=1024,tx_queue_size=256,bootindex=300,host_mtu=1500,host_tunnel=off' \
+  -machine 'pflash0=pflash0,pflash1=drive-efidisk0,hpet=off,type=q35+pve0'
diff --git a/src/test/cfg2cmd/q35-grouped-virtiofs-viommu.conf b/src/test/cfg2cmd/q35-grouped-virtiofs-viommu.conf
new file mode 100644
index 00000000..ddc3165b
--- /dev/null
+++ b/src/test/cfg2cmd/q35-grouped-virtiofs-viommu.conf
@@ -0,0 +1,14 @@
+# TEST: q35 with grouped pci-layout places virtiofs and virtio-iommu on their reserved addresses
+bios: ovmf
+bootdisk: scsi0
+cores: 2
+efidisk0: local:100/vm-100-disk-1.qcow2,size=128K
+machine: q35,pci-layout=grouped,viommu=virtio
+memory: 512
+net0: virtio=2E:01:68:F9:9C:87,bridge=vmbr0
+ostype: l26
+scsihw: virtio-scsi-pci
+smbios1: uuid=3dd750ce-d910-44d0-9493-525c0be4e687
+virtiofs0: share1
+virtiofs1: share2,cache=always
+vmgenid: 54d1c06c-8f5b-440f-b5b2-6eab1380e13d
diff --git a/src/test/cfg2cmd/q35-grouped-virtiofs-viommu.conf.cmd b/src/test/cfg2cmd/q35-grouped-virtiofs-viommu.conf.cmd
new file mode 100644
index 00000000..ce9c13de
--- /dev/null
+++ b/src/test/cfg2cmd/q35-grouped-virtiofs-viommu.conf.cmd
@@ -0,0 +1,46 @@
+/usr/bin/kvm \
+  -id 8006 \
+  -name vm8006 \
+  -no-shutdown \
+  -chardev 'socket,id=qmp,path=/var/run/qemu-server/8006.qmp,server=on,wait=off' \
+  -object 'monitor-qmp,chardev=qmp,id=monitor-qmp' \
+  -chardev 'socket,id=qmp-event,path=/var/run/qmeventd.sock,reconnect-ms=5000' \
+  -object 'monitor-qmp,chardev=qmp-event,id=monitor-qmp-event' \
+  -pidfile /var/run/qemu-server/8006.pid \
+  -daemonize \
+  -smbios 'type=1,uuid=3dd750ce-d910-44d0-9493-525c0be4e687' \
+  -object '{"id":"throttle-drive-efidisk0","limits":{},"qom-type":"throttle-group"}' \
+  -blockdev '{"driver":"raw","file":{"driver":"file","filename":"/usr/share/pve-edk2-firmware//OVMF_CODE.fd"},"node-name":"pflash0","read-only":true}' \
+  -blockdev '{"detect-zeroes":"on","discard":"ignore","driver":"throttle","file":{"cache":{"direct":false,"no-flush":false},"detect-zeroes":"on","discard":"ignore","driver":"qcow2","file":{"aio":"io_uring","cache":{"direct":false,"no-flush":false},"detect-zeroes":"on","discard":"ignore","driver":"file","filename":"/var/lib/vz/images/100/vm-100-disk-1.qcow2","node-name":"e70e3017c5a79fdee5a04aa92ac1e9c","read-only":false},"node-name":"f70e3017c5a79fdee5a04aa92ac1e9c","read-only":false},"node-name":"drive-efidisk0","read-only":false,"throttle-group":"throttle-drive-efidisk0"}' \
+  -global 'ICH9-LPC.acpi-pci-hotplug-with-bridge-support=off' \
+  -smp '2,sockets=1,cores=2,maxcpus=2' \
+  -nodefaults \
+  -boot 'menu=on,strict=on,reboot-timeout=1000,splash=/usr/share/qemu-server/bootsplash.jpg' \
+  -vnc 'unix:/var/run/qemu-server/8006.vnc,password=on' \
+  -cpu kvm64,enforce,+kvm_pv_eoi,+kvm_pv_unhalt,+lahf_lm,+sep \
+  -m 512 \
+  -object 'memory-backend-memfd,id=virtiofs-mem,size=512M,share=on' \
+  -global 'ICH9-LPC.disable_s3=1' \
+  -global 'ICH9-LPC.disable_s4=1' \
+  -device 'pcie-pci-bridge,id=pve.sys0,bus=pcie.0,addr=0x4,multifunction=on' \
+  -device 'pcie-pci-bridge,id=pve.virtio-fs0,bus=pcie.0,addr=0x5,multifunction=on' \
+  -device 'pcie-pci-bridge,id=pve.net0,bus=pcie.0,addr=0x6,multifunction=on' \
+  -device 'pcie-pci-bridge,id=pve.net1,bus=pcie.0,addr=0x6.1' \
+  -device 'pcie-pci-bridge,id=pve.scsi0,bus=pcie.0,addr=0x7,multifunction=on' \
+  -device 'pcie-pci-bridge,id=pve.virtio-blk0,bus=pcie.0,addr=0x8,multifunction=on' \
+  -device 'pcie-pci-bridge,id=pve.hostpci0,bus=pcie.0,addr=0x9,multifunction=on' \
+  -device 'vmgenid,guid=54d1c06c-8f5b-440f-b5b2-6eab1380e13d' \
+  -device 'qemu-xhci,p2=15,p3=15,id=xhci,bus=pve.sys0,addr=0x3' \
+  -device 'usb-hub,id=hub,bus=xhci.0,port=15' \
+  -device 'usb-tablet,id=tablet,bus=xhci.0,port=15.7' \
+  -device 'VGA,id=vga,bus=pcie.0,addr=0x2' \
+  -device 'virtio-balloon-pci,id=balloon0,bus=pve.sys0,addr=0x2,free-page-reporting=on' \
+  -iscsi 'initiator-name=iqn.1993-08.org.debian:01:aabbccddeeff' \
+  -netdev 'type=tap,id=net0,ifname=tap8006i0,script=/usr/libexec/qemu-server/pve-bridge,downscript=/usr/libexec/qemu-server/pve-bridgedown,vhost=on' \
+  -device 'virtio-net-pci,mac=2E:01:68:F9:9C:87,netdev=net0,bus=pve.net0,addr=0x1,id=net0,rx_queue_size=1024,tx_queue_size=256,bootindex=300,host_mtu=1500,host_tunnel=off' \
+  -device 'virtio-iommu-pci,bus=pcie.0,addr=0x3' \
+  -chardev 'socket,id=virtiofs0,path=/run/qemu-server/virtiofsd/vm8006-fs0' \
+  -device 'vhost-user-fs-pci,queue-size=1024,chardev=virtiofs0,tag=share1,bus=pve.virtio-fs0,addr=0x1' \
+  -chardev 'socket,id=virtiofs1,path=/run/qemu-server/virtiofsd/vm8006-fs1' \
+  -device 'vhost-user-fs-pci,queue-size=1024,chardev=virtiofs1,tag=share2,bus=pve.virtio-fs0,addr=0x2' \
+  -machine 'pflash0=pflash0,pflash1=drive-efidisk0,hpet=off,memory-backend=virtiofs-mem,type=q35+pve0'
diff --git a/src/test/cfg2cmd/simple-grouped-virtiofs-viommu.conf b/src/test/cfg2cmd/simple-grouped-virtiofs-viommu.conf
new file mode 100644
index 00000000..48e62907
--- /dev/null
+++ b/src/test/cfg2cmd/simple-grouped-virtiofs-viommu.conf
@@ -0,0 +1,16 @@
+# TEST: i440fx with grouped pci-layout places virtiofs and virtio-iommu on their reserved addresses
+bootdisk: scsi0
+cores: 3
+ide2: none,media=cdrom
+machine: pc,pci-layout=grouped,viommu=virtio
+memory: 768
+name: simple
+net0: virtio=A2:C0:43:77:08:A0,bridge=vmbr0
+numa: 0
+ostype: l26
+scsi0: local:8006/vm-8006-disk-0.qcow2,discard=on,size=104858K
+scsihw: virtio-scsi-pci
+smbios1: uuid=7b10d7af-b932-4c66-b2c3-3996152ec465
+sockets: 1
+virtiofs0: share1
+vmgenid: c773c261-d800-4348-9f5d-167fadd53cf8
diff --git a/src/test/cfg2cmd/simple-grouped-virtiofs-viommu.conf.cmd b/src/test/cfg2cmd/simple-grouped-virtiofs-viommu.conf.cmd
new file mode 100644
index 00000000..a6ec8c0c
--- /dev/null
+++ b/src/test/cfg2cmd/simple-grouped-virtiofs-viommu.conf.cmd
@@ -0,0 +1,45 @@
+/usr/bin/kvm \
+  -id 8006 \
+  -name simple \
+  -no-shutdown \
+  -chardev 'socket,id=qmp,path=/var/run/qemu-server/8006.qmp,server=on,wait=off' \
+  -object 'monitor-qmp,chardev=qmp,id=monitor-qmp' \
+  -chardev 'socket,id=qmp-event,path=/var/run/qmeventd.sock,reconnect-ms=5000' \
+  -object 'monitor-qmp,chardev=qmp-event,id=monitor-qmp-event' \
+  -pidfile /var/run/qemu-server/8006.pid \
+  -daemonize \
+  -smbios 'type=1,uuid=7b10d7af-b932-4c66-b2c3-3996152ec465' \
+  -smp '3,sockets=1,cores=3,maxcpus=3' \
+  -nodefaults \
+  -boot 'menu=on,strict=on,reboot-timeout=1000,splash=/usr/share/qemu-server/bootsplash.jpg' \
+  -vnc 'unix:/var/run/qemu-server/8006.vnc,password=on' \
+  -cpu kvm64,enforce,+kvm_pv_eoi,+kvm_pv_unhalt,+lahf_lm,+sep \
+  -m 768 \
+  -object 'memory-backend-memfd,id=virtiofs-mem,size=768M,share=on' \
+  -object '{"id":"throttle-drive-scsi0","limits":{},"qom-type":"throttle-group"}' \
+  -global 'PIIX4_PM.disable_s3=1' \
+  -global 'PIIX4_PM.disable_s4=1' \
+  -device 'pci-bridge,id=pve.sys0,chassis_nr=1,bus=pci.0,addr=0x4,multifunction=on' \
+  -device 'pci-bridge,id=pve.virtio-fs0,chassis_nr=2,bus=pci.0,addr=0x5,multifunction=on' \
+  -device 'pci-bridge,id=pve.net0,chassis_nr=3,bus=pci.0,addr=0x6,multifunction=on' \
+  -device 'pci-bridge,id=pve.net1,chassis_nr=4,bus=pci.0,addr=0x6.1' \
+  -device 'pci-bridge,id=pve.scsi0,chassis_nr=5,bus=pci.0,addr=0x7,multifunction=on' \
+  -device 'pci-bridge,id=pve.virtio-blk0,chassis_nr=6,bus=pci.0,addr=0x8,multifunction=on' \
+  -device 'pci-bridge,id=pve.hostpci0,chassis_nr=7,bus=pci.0,addr=0x9,multifunction=on' \
+  -device 'vmgenid,guid=c773c261-d800-4348-9f5d-167fadd53cf8' \
+  -device 'qemu-xhci,p2=15,p3=15,id=xhci,bus=pve.sys0,addr=0x3' \
+  -device 'usb-hub,id=hub,bus=xhci.0,port=15' \
+  -device 'usb-tablet,id=tablet,bus=xhci.0,port=15.7' \
+  -device 'VGA,id=vga,bus=pci.0,addr=0x2' \
+  -device 'virtio-balloon-pci,id=balloon0,bus=pve.sys0,addr=0x2,free-page-reporting=on' \
+  -iscsi 'initiator-name=iqn.1993-08.org.debian:01:aabbccddeeff' \
+  -device 'ide-cd,bus=ide.1,unit=0,id=ide2,bootindex=200' \
+  -device 'virtio-scsi-pci,id=scsihw0,bus=pve.scsi0,addr=0x1' \
+  -blockdev '{"detect-zeroes":"unmap","discard":"unmap","driver":"throttle","file":{"cache":{"direct":true,"no-flush":false},"detect-zeroes":"unmap","discard":"unmap","driver":"qcow2","file":{"aio":"io_uring","cache":{"direct":true,"no-flush":false},"detect-zeroes":"unmap","discard":"unmap","driver":"file","filename":"/var/lib/vz/images/8006/vm-8006-disk-0.qcow2","node-name":"ecd04be4259153b8293415fefa2a84c","read-only":false},"node-name":"fcd04be4259153b8293415fefa2a84c","read-only":false},"node-name":"drive-scsi0","read-only":false,"throttle-group":"throttle-drive-scsi0"}' \
+  -device 'scsi-hd,bus=scsihw0.0,channel=0,scsi-id=0,lun=0,drive=drive-scsi0,id=scsi0,device_id=drive-scsi0,bootindex=100,write-cache=on' \
+  -netdev 'type=tap,id=net0,ifname=tap8006i0,script=/usr/libexec/qemu-server/pve-bridge,downscript=/usr/libexec/qemu-server/pve-bridgedown,vhost=on' \
+  -device 'virtio-net-pci,mac=A2:C0:43:77:08:A0,netdev=net0,bus=pve.net0,addr=0x1,id=net0,rx_queue_size=1024,tx_queue_size=256,bootindex=300,host_mtu=1500,host_tunnel=off' \
+  -device 'virtio-iommu-pci,bus=pci.0,addr=0x3' \
+  -chardev 'socket,id=virtiofs0,path=/run/qemu-server/virtiofsd/vm8006-fs0' \
+  -device 'vhost-user-fs-pci,queue-size=1024,chardev=virtiofs0,tag=share1,bus=pve.virtio-fs0,addr=0x1' \
+  -machine 'hpet=off,memory-backend=virtiofs-mem,type=pc+pve0'
diff --git a/src/test/cfg2cmd/simple-grouped.conf b/src/test/cfg2cmd/simple-grouped.conf
new file mode 100644
index 00000000..b9b3bb34
--- /dev/null
+++ b/src/test/cfg2cmd/simple-grouped.conf
@@ -0,0 +1,15 @@
+# TEST: Simple test for a basic configuration with grouped pci layout
+bootdisk: scsi0
+cores: 3
+ide2: none,media=cdrom
+machine: pc,pci-layout=grouped
+memory: 768
+name: simple
+net0: virtio=A2:C0:43:77:08:A0,bridge=vmbr0
+numa: 0
+ostype: l26
+scsi0: local:8006/vm-8006-disk-0.qcow2,discard=on,size=104858K
+scsihw: virtio-scsi-pci
+smbios1: uuid=7b10d7af-b932-4c66-b2c3-3996152ec465
+sockets: 1
+vmgenid: c773c261-d800-4348-9f5d-167fadd53cf8
diff --git a/src/test/cfg2cmd/simple-grouped.conf.cmd b/src/test/cfg2cmd/simple-grouped.conf.cmd
new file mode 100644
index 00000000..c078b037
--- /dev/null
+++ b/src/test/cfg2cmd/simple-grouped.conf.cmd
@@ -0,0 +1,41 @@
+/usr/bin/kvm \
+  -id 8006 \
+  -name simple \
+  -no-shutdown \
+  -chardev 'socket,id=qmp,path=/var/run/qemu-server/8006.qmp,server=on,wait=off' \
+  -object 'monitor-qmp,chardev=qmp,id=monitor-qmp' \
+  -chardev 'socket,id=qmp-event,path=/var/run/qmeventd.sock,reconnect-ms=5000' \
+  -object 'monitor-qmp,chardev=qmp-event,id=monitor-qmp-event' \
+  -pidfile /var/run/qemu-server/8006.pid \
+  -daemonize \
+  -smbios 'type=1,uuid=7b10d7af-b932-4c66-b2c3-3996152ec465' \
+  -smp '3,sockets=1,cores=3,maxcpus=3' \
+  -nodefaults \
+  -boot 'menu=on,strict=on,reboot-timeout=1000,splash=/usr/share/qemu-server/bootsplash.jpg' \
+  -vnc 'unix:/var/run/qemu-server/8006.vnc,password=on' \
+  -cpu kvm64,enforce,+kvm_pv_eoi,+kvm_pv_unhalt,+lahf_lm,+sep \
+  -m 768 \
+  -object '{"id":"throttle-drive-scsi0","limits":{},"qom-type":"throttle-group"}' \
+  -global 'PIIX4_PM.disable_s3=1' \
+  -global 'PIIX4_PM.disable_s4=1' \
+  -device 'pci-bridge,id=pve.sys0,chassis_nr=1,bus=pci.0,addr=0x4,multifunction=on' \
+  -device 'pci-bridge,id=pve.virtio-fs0,chassis_nr=2,bus=pci.0,addr=0x5,multifunction=on' \
+  -device 'pci-bridge,id=pve.net0,chassis_nr=3,bus=pci.0,addr=0x6,multifunction=on' \
+  -device 'pci-bridge,id=pve.net1,chassis_nr=4,bus=pci.0,addr=0x6.1' \
+  -device 'pci-bridge,id=pve.scsi0,chassis_nr=5,bus=pci.0,addr=0x7,multifunction=on' \
+  -device 'pci-bridge,id=pve.virtio-blk0,chassis_nr=6,bus=pci.0,addr=0x8,multifunction=on' \
+  -device 'pci-bridge,id=pve.hostpci0,chassis_nr=7,bus=pci.0,addr=0x9,multifunction=on' \
+  -device 'vmgenid,guid=c773c261-d800-4348-9f5d-167fadd53cf8' \
+  -device 'qemu-xhci,p2=15,p3=15,id=xhci,bus=pve.sys0,addr=0x3' \
+  -device 'usb-hub,id=hub,bus=xhci.0,port=15' \
+  -device 'usb-tablet,id=tablet,bus=xhci.0,port=15.7' \
+  -device 'VGA,id=vga,bus=pci.0,addr=0x2' \
+  -device 'virtio-balloon-pci,id=balloon0,bus=pve.sys0,addr=0x2,free-page-reporting=on' \
+  -iscsi 'initiator-name=iqn.1993-08.org.debian:01:aabbccddeeff' \
+  -device 'ide-cd,bus=ide.1,unit=0,id=ide2,bootindex=200' \
+  -device 'virtio-scsi-pci,id=scsihw0,bus=pve.scsi0,addr=0x1' \
+  -blockdev '{"detect-zeroes":"unmap","discard":"unmap","driver":"throttle","file":{"cache":{"direct":true,"no-flush":false},"detect-zeroes":"unmap","discard":"unmap","driver":"qcow2","file":{"aio":"io_uring","cache":{"direct":true,"no-flush":false},"detect-zeroes":"unmap","discard":"unmap","driver":"file","filename":"/var/lib/vz/images/8006/vm-8006-disk-0.qcow2","node-name":"ecd04be4259153b8293415fefa2a84c","read-only":false},"node-name":"fcd04be4259153b8293415fefa2a84c","read-only":false},"node-name":"drive-scsi0","read-only":false,"throttle-group":"throttle-drive-scsi0"}' \
+  -device 'scsi-hd,bus=scsihw0.0,channel=0,scsi-id=0,lun=0,drive=drive-scsi0,id=scsi0,device_id=drive-scsi0,bootindex=100,write-cache=on' \
+  -netdev 'type=tap,id=net0,ifname=tap8006i0,script=/usr/libexec/qemu-server/pve-bridge,downscript=/usr/libexec/qemu-server/pve-bridgedown,vhost=on' \
+  -device 'virtio-net-pci,mac=A2:C0:43:77:08:A0,netdev=net0,bus=pve.net0,addr=0x1,id=net0,rx_queue_size=1024,tx_queue_size=256,bootindex=300,host_mtu=1500,host_tunnel=off' \
+  -machine 'hpet=off,type=pc+pve0'
diff --git a/src/test/cfg2cmd/spice-win-grouped.conf b/src/test/cfg2cmd/spice-win-grouped.conf
new file mode 100644
index 00000000..5cf7aea5
--- /dev/null
+++ b/src/test/cfg2cmd/spice-win-grouped.conf
@@ -0,0 +1,14 @@
+# TEST: Windows with SPICE multi-monitor (qxl3), a SPICE USB port & grouped pci-layout
+bios: ovmf
+cores: 2
+efidisk0: local:100/vm-100-disk-1.qcow2,size=128K
+machine: pc-q35-11.1,pci-layout=grouped
+memory: 2048
+name: spicewin
+net0: virtio=A2:C0:43:77:08:A1,bridge=vmbr0
+ostype: win11
+scsihw: virtio-scsi-pci
+smbios1: uuid=7b10d7af-b932-4c66-b2c3-3996152ec461
+usb0: spice
+vga: qxl3
+vmgenid: c773c261-d800-4348-9f5d-167fadd53cf7
diff --git a/src/test/cfg2cmd/spice-win-grouped.conf.cmd b/src/test/cfg2cmd/spice-win-grouped.conf.cmd
new file mode 100644
index 00000000..1e8320a3
--- /dev/null
+++ b/src/test/cfg2cmd/spice-win-grouped.conf.cmd
@@ -0,0 +1,48 @@
+/usr/bin/kvm \
+  -id 8006 \
+  -name spicewin \
+  -no-shutdown \
+  -chardev 'socket,id=qmp,path=/var/run/qemu-server/8006.qmp,server=on,wait=off' \
+  -object 'monitor-qmp,chardev=qmp,id=monitor-qmp' \
+  -chardev 'socket,id=qmp-event,path=/var/run/qmeventd.sock,reconnect-ms=5000' \
+  -object 'monitor-qmp,chardev=qmp-event,id=monitor-qmp-event' \
+  -pidfile /var/run/qemu-server/8006.pid \
+  -daemonize \
+  -smbios 'type=1,uuid=7b10d7af-b932-4c66-b2c3-3996152ec461' \
+  -object '{"id":"throttle-drive-efidisk0","limits":{},"qom-type":"throttle-group"}' \
+  -blockdev '{"driver":"raw","file":{"driver":"file","filename":"/usr/share/pve-edk2-firmware//OVMF_CODE.fd"},"node-name":"pflash0","read-only":true}' \
+  -blockdev '{"detect-zeroes":"on","discard":"ignore","driver":"throttle","file":{"cache":{"direct":false,"no-flush":false},"detect-zeroes":"on","discard":"ignore","driver":"qcow2","file":{"aio":"io_uring","cache":{"direct":false,"no-flush":false},"detect-zeroes":"on","discard":"ignore","driver":"file","filename":"/var/lib/vz/images/100/vm-100-disk-1.qcow2","node-name":"e70e3017c5a79fdee5a04aa92ac1e9c","read-only":false},"node-name":"f70e3017c5a79fdee5a04aa92ac1e9c","read-only":false},"node-name":"drive-efidisk0","read-only":false,"throttle-group":"throttle-drive-efidisk0"}' \
+  -smp '2,sockets=1,cores=2,maxcpus=2' \
+  -nodefaults \
+  -boot 'menu=on,strict=on,reboot-timeout=1000,splash=/usr/share/qemu-server/bootsplash.jpg' \
+  -vnc 'unix:/var/run/qemu-server/8006.vnc,password=on' \
+  -global 'kvm-pit.lost_tick_policy=discard' \
+  -cpu 'kvm64,-cet-ibt,-cet-ss,enforce,hv_evmcs,hv_ipi,hv_relaxed,hv_reset,hv_runtime,hv_spinlocks=0x1fff,hv_stimer,hv_synic,hv_time,hv_vapic,hv_vpindex,+kvm_pv_eoi,+kvm_pv_unhalt,+lahf_lm,+sep' \
+  -m 2048 \
+  -global 'ICH9-LPC.disable_s3=1' \
+  -global 'ICH9-LPC.disable_s4=1' \
+  -device 'pcie-pci-bridge,id=pve.sys0,bus=pcie.0,addr=0x4,multifunction=on' \
+  -device 'pcie-pci-bridge,id=pve.virtio-fs0,bus=pcie.0,addr=0x5,multifunction=on' \
+  -device 'pcie-pci-bridge,id=pve.net0,bus=pcie.0,addr=0x6,multifunction=on' \
+  -device 'pcie-pci-bridge,id=pve.net1,bus=pcie.0,addr=0x6.1' \
+  -device 'pcie-pci-bridge,id=pve.scsi0,bus=pcie.0,addr=0x7,multifunction=on' \
+  -device 'pcie-pci-bridge,id=pve.virtio-blk0,bus=pcie.0,addr=0x8,multifunction=on' \
+  -device 'pcie-pci-bridge,id=pve.hostpci0,bus=pcie.0,addr=0x9,multifunction=on' \
+  -device 'vmgenid,guid=c773c261-d800-4348-9f5d-167fadd53cf7' \
+  -device 'qemu-xhci,p2=15,p3=15,id=xhci,bus=pve.sys0,addr=0x3' \
+  -device 'usb-hub,id=hub,bus=xhci.0,port=15' \
+  -chardev 'spicevmc,id=usbredirchardev0,name=usbredir' \
+  -device 'usb-redir,chardev=usbredirchardev0,id=usbredirdev0,bus=xhci.0,port=1' \
+  -device 'qxl-vga,id=vga,bus=pcie.0,addr=0x2,multifunction=on' \
+  -device 'qxl,id=vga1,ram_size=67108864,vram_size=33554432,bus=pcie.0,addr=0x2.1' \
+  -device 'qxl,id=vga2,ram_size=67108864,vram_size=33554432,bus=pcie.0,addr=0x2.2' \
+  -device 'virtio-serial,id=spice,bus=pve.sys0,addr=0x5' \
+  -chardev 'spicevmc,id=vdagent,name=vdagent' \
+  -device 'virtserialport,chardev=vdagent,name=com.redhat.spice.0' \
+  -spice 'tls-port=61000,addr=127.0.0.1,tls-ciphers=HIGH,seamless-migration=on' \
+  -device 'virtio-balloon-pci,id=balloon0,bus=pve.sys0,addr=0x2,free-page-reporting=on' \
+  -iscsi 'initiator-name=iqn.1993-08.org.debian:01:aabbccddeeff' \
+  -netdev 'type=tap,id=net0,ifname=tap8006i0,script=/usr/libexec/qemu-server/pve-bridge,downscript=/usr/libexec/qemu-server/pve-bridgedown,vhost=on' \
+  -device 'virtio-net-pci,mac=A2:C0:43:77:08:A1,netdev=net0,bus=pve.net0,addr=0x1,id=net0,rx_queue_size=1024,tx_queue_size=256,bootindex=300,host_mtu=1500,host_tunnel=off' \
+  -rtc 'driftfix=slew,base=localtime' \
+  -machine 'pflash0=pflash0,pflash1=drive-efidisk0,hpet=off,type=pc-q35-11.1+pve0'
diff --git a/src/test/cfg2cmd/virtiofs-viommu.conf b/src/test/cfg2cmd/virtiofs-viommu.conf
new file mode 100644
index 00000000..83cd1907
--- /dev/null
+++ b/src/test/cfg2cmd/virtiofs-viommu.conf
@@ -0,0 +1,16 @@
+# TEST: legacy pci-layout keeps virtiofs and virtio-iommu without an explicit address
+bootdisk: scsi0
+cores: 3
+ide2: none,media=cdrom
+machine: pc,viommu=virtio
+memory: 768
+name: simple
+net0: virtio=A2:C0:43:77:08:A0,bridge=vmbr0
+numa: 0
+ostype: l26
+scsi0: local:8006/vm-8006-disk-0.qcow2,discard=on,size=104858K
+scsihw: virtio-scsi-pci
+smbios1: uuid=7b10d7af-b932-4c66-b2c3-3996152ec465
+sockets: 1
+virtiofs0: share1
+vmgenid: c773c261-d800-4348-9f5d-167fadd53cf8
diff --git a/src/test/cfg2cmd/virtiofs-viommu.conf.cmd b/src/test/cfg2cmd/virtiofs-viommu.conf.cmd
new file mode 100644
index 00000000..8fb0c249
--- /dev/null
+++ b/src/test/cfg2cmd/virtiofs-viommu.conf.cmd
@@ -0,0 +1,40 @@
+/usr/bin/kvm \
+  -id 8006 \
+  -name simple \
+  -no-shutdown \
+  -chardev 'socket,id=qmp,path=/var/run/qemu-server/8006.qmp,server=on,wait=off' \
+  -object 'monitor-qmp,chardev=qmp,id=monitor-qmp' \
+  -chardev 'socket,id=qmp-event,path=/var/run/qmeventd.sock,reconnect-ms=5000' \
+  -object 'monitor-qmp,chardev=qmp-event,id=monitor-qmp-event' \
+  -pidfile /var/run/qemu-server/8006.pid \
+  -daemonize \
+  -smbios 'type=1,uuid=7b10d7af-b932-4c66-b2c3-3996152ec465' \
+  -smp '3,sockets=1,cores=3,maxcpus=3' \
+  -nodefaults \
+  -boot 'menu=on,strict=on,reboot-timeout=1000,splash=/usr/share/qemu-server/bootsplash.jpg' \
+  -vnc 'unix:/var/run/qemu-server/8006.vnc,password=on' \
+  -cpu kvm64,enforce,+kvm_pv_eoi,+kvm_pv_unhalt,+lahf_lm,+sep \
+  -m 768 \
+  -object 'memory-backend-memfd,id=virtiofs-mem,size=768M,share=on' \
+  -object '{"id":"throttle-drive-scsi0","limits":{},"qom-type":"throttle-group"}' \
+  -global 'PIIX4_PM.disable_s3=1' \
+  -global 'PIIX4_PM.disable_s4=1' \
+  -device 'pci-bridge,id=pci.1,chassis_nr=1,bus=pci.0,addr=0x1e' \
+  -device 'pci-bridge,id=pci.2,chassis_nr=2,bus=pci.0,addr=0x1f' \
+  -device 'pci-bridge,id=pci.4,chassis_nr=4,bus=pci.1,addr=0x1c' \
+  -device 'vmgenid,guid=c773c261-d800-4348-9f5d-167fadd53cf8' \
+  -device 'piix3-usb-uhci,id=uhci,bus=pci.0,addr=0x1.0x2' \
+  -device 'usb-tablet,id=tablet,bus=uhci.0,port=1' \
+  -device 'VGA,id=vga,bus=pci.0,addr=0x2' \
+  -device 'virtio-balloon-pci,id=balloon0,bus=pci.0,addr=0x3,free-page-reporting=on' \
+  -iscsi 'initiator-name=iqn.1993-08.org.debian:01:aabbccddeeff' \
+  -device 'ide-cd,bus=ide.1,unit=0,id=ide2,bootindex=200' \
+  -device 'virtio-scsi-pci,id=scsihw0,bus=pci.0,addr=0x5' \
+  -blockdev '{"detect-zeroes":"unmap","discard":"unmap","driver":"throttle","file":{"cache":{"direct":true,"no-flush":false},"detect-zeroes":"unmap","discard":"unmap","driver":"qcow2","file":{"aio":"io_uring","cache":{"direct":true,"no-flush":false},"detect-zeroes":"unmap","discard":"unmap","driver":"file","filename":"/var/lib/vz/images/8006/vm-8006-disk-0.qcow2","node-name":"ecd04be4259153b8293415fefa2a84c","read-only":false},"node-name":"fcd04be4259153b8293415fefa2a84c","read-only":false},"node-name":"drive-scsi0","read-only":false,"throttle-group":"throttle-drive-scsi0"}' \
+  -device 'scsi-hd,bus=scsihw0.0,channel=0,scsi-id=0,lun=0,drive=drive-scsi0,id=scsi0,device_id=drive-scsi0,bootindex=100,write-cache=on' \
+  -netdev 'type=tap,id=net0,ifname=tap8006i0,script=/usr/libexec/qemu-server/pve-bridge,downscript=/usr/libexec/qemu-server/pve-bridgedown,vhost=on' \
+  -device 'virtio-net-pci,mac=A2:C0:43:77:08:A0,netdev=net0,bus=pci.0,addr=0x12,id=net0,rx_queue_size=1024,tx_queue_size=256,bootindex=300,host_mtu=1500,host_tunnel=off' \
+  -device virtio-iommu-pci \
+  -chardev 'socket,id=virtiofs0,path=/run/qemu-server/virtiofsd/vm8006-fs0' \
+  -device 'vhost-user-fs-pci,queue-size=1024,chardev=virtiofs0,tag=share1' \
+  -machine 'hpet=off,memory-backend=virtiofs-mem,type=pc+pve0'
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 12+ messages in thread

* Re: [PATCH proxmox-perl-rs v2 5/9] pve: add bindings for `pve-qemu-server-pci` crate
  2026-10-15  8:10 ` [PATCH proxmox-perl-rs v2 5/9] pve: add bindings for `pve-qemu-server-pci` crate Dominik Csapak
@ 2026-10-15 11:47   ` Wolfgang Bumiller
  0 siblings, 0 replies; 12+ messages in thread
From: Wolfgang Bumiller @ 2026-10-15 11:47 UTC (permalink / raw)
  To: Dominik Csapak; +Cc: pve-devel

On Thu, Oct 15, 2026 at 10:10:27AM +0200, Dominik Csapak wrote:
> These provide bindings for using the rust pci code in PVE's qemu-server
> perl code. This exposes 'PVE::RS::PCI::HardwareConfig' which provides an
> object that can be used to query the pci addresses of qemu vms.
> 
> Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
> ---
>  pve-rs/Cargo.toml                          |   1 +
>  pve-rs/Makefile                            |   1 +
>  pve-rs/src/bindings/mod.rs                 |   3 +
>  pve-rs/src/bindings/pci/hardware_config.rs | 150 +++++++++++++++++++++
>  pve-rs/src/bindings/pci/mod.rs             |   2 +
>  5 files changed, 157 insertions(+)
>  create mode 100644 pve-rs/src/bindings/pci/hardware_config.rs
>  create mode 100644 pve-rs/src/bindings/pci/mod.rs
> 
> diff --git a/pve-rs/Cargo.toml b/pve-rs/Cargo.toml
> index 5ae9082..0c4a47e 100644
> --- a/pve-rs/Cargo.toml
> +++ b/pve-rs/Cargo.toml
> @@ -52,6 +52,7 @@ proxmox-tfa = { version = "6.0.3", features = ["api"] }
>  proxmox-time = "2"
>  proxmox-ve-config = { version = "0.10.4", features = [ "frr" ] }
>  proxmox-wireguard = { version = "0.1.2" }
> +pve-qemu-server-pci = "0.1"
>  
>  # [patch.crates-io]
>  # pbs-api-types = { path = "../../proxmox/pbs-api-types" }
> diff --git a/pve-rs/Makefile b/pve-rs/Makefile
> index bb1cd2d..c239f2a 100644
> --- a/pve-rs/Makefile
> +++ b/pve-rs/Makefile
> @@ -29,6 +29,7 @@ PERLMOD_PACKAGES := \
>  	  PVE::RS::Firewall::SDN \
>  	  PVE::RS::NVML \
>  	  PVE::RS::OCI \
> +	  PVE::RS::PCI::HardwareConfig \
>  	  PVE::RS::OpenId \
>  	  PVE::RS::ResourceScheduling::Static \
>  	  PVE::RS::ResourceScheduling::Dynamic \
> diff --git a/pve-rs/src/bindings/mod.rs b/pve-rs/src/bindings/mod.rs
> index f922982..b26c602 100644
> --- a/pve-rs/src/bindings/mod.rs
> +++ b/pve-rs/src/bindings/mod.rs
> @@ -6,6 +6,9 @@ pub use nvml::pve_rs_nvml;
>  mod oci;
>  pub use oci::pve_rs_oci;
>  
> +mod pci;
> +pub use pci::pve_rs_pci_hardware_config;
> +
>  pub mod resource_scheduling;
>  
>  mod tfa;
> diff --git a/pve-rs/src/bindings/pci/hardware_config.rs b/pve-rs/src/bindings/pci/hardware_config.rs
> new file mode 100644
> index 0000000..44e9d03
> --- /dev/null
> +++ b/pve-rs/src/bindings/pci/hardware_config.rs
> @@ -0,0 +1,150 @@
> +#[perlmod::package(name = "PVE::RS::PCI::HardwareConfig", lib = "pve_rs")]
> +pub mod pve_rs_pci_hardware_config {
> +    //! The `PVE::RS::PCI::HardwareConfig` package.
> +    //!
> +    //! Provides bindings for the `HardwareConfig` struct of the [`pve-qemu-server-pci`] crate.
> +
> +    use std::ops::Deref;
> +    use std::rc::Rc;
> +
> +    use anyhow::Error;
> +
> +    use perlmod::Value;
> +
> +    use pve_qemu_server_pci::{HostpcieDevice, LayoutError, address};
> +
> +    perlmod::declare_magic!(Box<HardwareConfig>: &HardwareConfig as "PVE::RS::PCI::HardwareConfig");
> +
> +    /// Wrapper for [`pve_qemu_server_pci::HardwareConfig`].
> +    pub struct HardwareConfig {
> +        inner: Rc<pve_qemu_server_pci::HardwareConfig>,
> +    }
> +
> +    impl Deref for HardwareConfig {
> +        type Target = pve_qemu_server_pci::HardwareConfig;
> +
> +        fn deref(&self) -> &Self::Target {
> +            &self.inner
> +        }
> +    }
> +
> +    #[export(raw_return)]
> +    #[allow(clippy::too_many_arguments)]

I think a struct parameter might make more sense here.
(A strict one with `#[serde(deny_unknown_fields)]`)

> +    /// Create an instance of the `HardwareConfig` struct.

As per the `README.md` the method here *should* include a
`Class method: ` prefix in its documentation.
(And the rest of them a `Method: ` prefix.)

> +    ///
> +    /// `hostpcie` lists the indices of the `hostpci` devices with `pcie` enabled.
> +    pub fn new(
> +        #[raw] class: Value,
> +        arch: String,
> +        machine_type: String,
> +        scsihw: Option<String>,
> +        max_scsihw: u8,
> +        legacy_igd: bool,
> +        ostype: Option<String>,
> +        major: u16,
> +        minor: u16,
> +        pve: Option<u16>,
> +        layout: &str,
> +        hostpcie: Vec<u8>,
> +    ) -> Result<Value, Error> {
> +        let mut hw_config = pve_qemu_server_pci::HardwareConfig::new(
> +            &arch,
> +            machine_type,
> +            scsihw,
> +            max_scsihw,
> +            legacy_igd,
> +            ostype,
> +            major,
> +            minor,
> +            pve,
> +            layout,
> +        );
> +        hw_config.hostpcie_devices = hostpcie
> +            .into_iter()
> +            .map(|index| (index, HostpcieDevice::default()))
> +            .collect();
> +        Ok(
> +            perlmod::instantiate_magic!(&class, MAGIC => Box::new(HardwareConfig { inner: Rc::new(hw_config) })),
> +        )
> +    }
> +
> +    #[export]
> +    /// Returns the PCI or PCIe address of the chosen device on the given machine if the device
> +    /// exists on the layout.
> +    pub fn addr(
> +        #[try_from_ref] this: &HardwareConfig,

Uses `this` for the parameter (as every other module does).

> +        id: &str,
> +    ) -> Result<Option<String>, LayoutError> {
> +        match address(this, id) {
> +            Ok(addr) => Ok(Some(addr.to_qemu_addr())),
> +            Err(LayoutError::NoAddressFound(_)) => Ok(None),
> +            Err(other) => Err(other),
> +        }
> +    }
> +
> +    #[export]
> +    /// Get the `-device` arguments of all bridges, root ports and chipset devices forming the
> +    /// buses of the guest, which have to come before any device on them.
> +    pub fn pci_bus_devices(
> +        #[try_from_ref] machine: &HardwareConfig,

But from here on out it's always `machine`.

We should stick to `this` for the perl "self" parameter name.

> +    ) -> Result<Vec<String>, LayoutError> {
> +        pve_qemu_server_pci::pci_bus_devices(machine)
> +    }
> +
> +    #[export]
> +    /// Whether the q35 chipset devices still have to be loaded from `pve-q35-4.0.cfg`.
> +    pub fn uses_q35_config_file(#[try_from_ref] machine: &HardwareConfig) -> bool {
> +        machine.uses_q35_config_file()
> +    }
> +
> +    #[export]
> +    /// Get the architecture of the machine
> +    pub fn arch(#[try_from_ref] machine: &HardwareConfig) -> String {
> +        machine.arch.to_string()
> +    }
> +
> +    #[export]
> +    /// Get the machines mainboard model
> +    pub fn machine_type(#[try_from_ref] machine: &HardwareConfig) -> String {
> +        machine.mainboard.to_string()
> +    }
> +
> +    #[export]
> +    /// Get the layout of the given machine.
> +    pub fn layout(#[try_from_ref] machine: &HardwareConfig) -> String {
> +        machine.layout.to_string()
> +    }
> +
> +    #[export]
> +    /// Checks if the given machine is at least the given version
> +    pub fn version_at_least(
> +        #[try_from_ref] machine: &HardwareConfig,
> +        major: u16,
> +        minor: u16,
> +        pve: Option<u16>,
> +    ) -> bool {
> +        machine.version_at_least(major, minor, pve)
> +    }
> +
> +    #[export]
> +    /// returns the windows version (if any)

^ s/r/R/

> +    pub fn windows_version(#[try_from_ref] machine: &HardwareConfig) -> u8 {
> +        machine.windows_version()
> +    }
> +
> +    #[export]
> +    /// Returns the bus the given device sits on, if it is a known device.
> +    pub fn pci_bridge_for_device(
> +        #[try_from_ref] machine: &HardwareConfig,
> +        device: &str,
> +    ) -> Option<String> {
> +        let bus = pve_qemu_server_pci::pci_bridge_for_device(machine, device)?;
> +        Some(bus.to_string())
> +    }
> +
> +    #[export]
> +    /// Returns the required '+pveX' version of the machine.
> +    pub fn required_pve_version(#[try_from_ref] machine: &HardwareConfig) -> u16 {
> +        machine.required_pve_version()
> +    }
> +}
> diff --git a/pve-rs/src/bindings/pci/mod.rs b/pve-rs/src/bindings/pci/mod.rs
> new file mode 100644
> index 0000000..d1b00dd
> --- /dev/null
> +++ b/pve-rs/src/bindings/pci/mod.rs
> @@ -0,0 +1,2 @@
> +mod hardware_config;
> +pub use hardware_config::pve_rs_pci_hardware_config;
> -- 
> 2.47.3
> 
> 
> 
> 
> 

-- 




^ permalink raw reply	[flat|nested] 12+ messages in thread

* Re: [PATCH pve-qemu-server-rs v2 1/9] add pve-qemu-server-pci crate for guest PCI address generation
  2026-10-15  8:10 ` [PATCH pve-qemu-server-rs v2 1/9] add pve-qemu-server-pci crate for guest PCI address generation Dominik Csapak
@ 2026-10-15 12:16   ` Wolfgang Bumiller
  0 siblings, 0 replies; 12+ messages in thread
From: Wolfgang Bumiller @ 2026-10-15 12:16 UTC (permalink / raw)
  To: Dominik Csapak; +Cc: pve-devel

On Thu, Oct 15, 2026 at 10:10:23AM +0200, Dominik Csapak wrote:
> Since the hardware layout, including PCI addresses, are part of the
> guest's ABI, the assignment has to stay stable for the whole lifetime
> of a VM. The Perl implementation in PVE::QemuServer::PCI encodes this as
> two flat hashes of hardcoded addresses, which is hard to extend without
> accidentally shifting an existing entry.
> 
> Describe the assignment as a layout instead: a tree of buses, slots and
> functions that says what may sit where, which addresses are reserved for
> something outside of our control, and which are still free. Looking up a
> device then means walking that tree, and adding a new device means
> filling in a free slot rather than picking a number by hand.
> 
> The layouts added here reproduce the current Perl address maps exactly,
> including the quirks for legacy IGD passthrough, virtio-scsi-single and
> the Windows 7 PCIe workaround. The tests cover every id the Perl maps
> contain (except win7's hostpciXbus0, since that will change in perl
> anyway).
> 
> Lookups take a `HardwareConfig`, which only contains the parts of the
> guest config that influence the layout and its quirks, so callers can
> build it from whatever config representation they have.
> 
> Also adds the cargo workspace and the build tooling the other Proxmox
> Rust repositories use to build Debian packages.
> 
> Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
> ---
>  .cargo/config.toml                           |   5 +
>  .gitignore                                   |  13 +
>  Cargo.toml                                   |  29 +
>  Makefile                                     | 153 +++++
>  build.sh                                     |  40 ++
>  bump.sh                                      |  44 ++
>  pve-qemu-server-pci/Cargo.toml               |  15 +
>  pve-qemu-server-pci/debian/changelog         |   5 +
>  pve-qemu-server-pci/debian/control           |  36 ++
>  pve-qemu-server-pci/debian/copyright         |  18 +
>  pve-qemu-server-pci/debian/debcargo.toml     |   7 +
>  pve-qemu-server-pci/src/constants.rs         |   7 +
>  pve-qemu-server-pci/src/error.rs             |  24 +
>  pve-qemu-server-pci/src/hardware_config.rs   | 156 +++++
>  pve-qemu-server-pci/src/layout/legacy.rs     | 628 +++++++++++++++++++
>  pve-qemu-server-pci/src/layout/mod.rs        |  48 ++
>  pve-qemu-server-pci/src/lib.rs               |  33 +
>  pve-qemu-server-pci/src/types/bus.rs         | 198 ++++++
>  pve-qemu-server-pci/src/types/device.rs      | 173 +++++
>  pve-qemu-server-pci/src/types/layout.rs      | 329 ++++++++++
>  pve-qemu-server-pci/src/types/mod.rs         |  11 +
>  pve-qemu-server-pci/src/types/pci_address.rs |  36 ++
>  rustfmt.toml                                 |   2 +
>  23 files changed, 2010 insertions(+)
>  create mode 100644 .cargo/config.toml
>  create mode 100644 .gitignore
>  create mode 100644 Cargo.toml
>  create mode 100644 Makefile
>  create mode 100755 build.sh
>  create mode 100755 bump.sh
>  create mode 100644 pve-qemu-server-pci/Cargo.toml
>  create mode 100644 pve-qemu-server-pci/debian/changelog
>  create mode 100644 pve-qemu-server-pci/debian/control
>  create mode 100644 pve-qemu-server-pci/debian/copyright
>  create mode 100644 pve-qemu-server-pci/debian/debcargo.toml
>  create mode 100644 pve-qemu-server-pci/src/constants.rs
>  create mode 100644 pve-qemu-server-pci/src/error.rs
>  create mode 100644 pve-qemu-server-pci/src/hardware_config.rs
>  create mode 100644 pve-qemu-server-pci/src/layout/legacy.rs
>  create mode 100644 pve-qemu-server-pci/src/layout/mod.rs
>  create mode 100644 pve-qemu-server-pci/src/lib.rs
>  create mode 100644 pve-qemu-server-pci/src/types/bus.rs
>  create mode 100644 pve-qemu-server-pci/src/types/device.rs
>  create mode 100644 pve-qemu-server-pci/src/types/layout.rs
>  create mode 100644 pve-qemu-server-pci/src/types/mod.rs
>  create mode 100644 pve-qemu-server-pci/src/types/pci_address.rs
>  create mode 100644 rustfmt.toml
> 
> diff --git a/.cargo/config.toml b/.cargo/config.toml
> new file mode 100644
> index 0000000..3b5b6e4
> --- /dev/null
> +++ b/.cargo/config.toml
> @@ -0,0 +1,5 @@
> +[source]
> +[source.debian-packages]
> +directory = "/usr/share/cargo/registry"
> +[source.crates-io]
> +replace-with = "debian-packages"
> diff --git a/.gitignore b/.gitignore
> new file mode 100644
> index 0000000..dbdfcdc
> --- /dev/null
> +++ b/.gitignore
> @@ -0,0 +1,13 @@
> +/target
> +/*/target
> +Cargo.lock
> +**/*.rs.bk
> +*.buildinfo
> +*.changes
> +*.deb
> +*.dsc
> +*.tar.?z
> +*.tar.zst
> +/build
> +/*-deb
> +/*-dsc
> diff --git a/Cargo.toml b/Cargo.toml
> new file mode 100644
> index 0000000..71bc8e5
> --- /dev/null
> +++ b/Cargo.toml
> @@ -0,0 +1,29 @@
> +[workspace]
> +members = [
> +    "pve-qemu-server-pci"
> +]
> +exclude = [
> +    "build",
> +]
> +resolver = "3"
> +
> +[workspace.package]
> +authors = ["Proxmox Support Team <support@proxmox.com>"]
> +edition = "2024"
> +license = "AGPL-3"
> +repository = "https://git.proxmox.com/?p=pve-qemu-server-rs.git"
> +homepage = "https://proxmox.com"
> +exclude = [ "debian" ]
> +rust-version = "1.94"
> +
> +[workspace.dependencies]
> +# any features enabled here are enabled on all members using 'workspace = true'!
> +
> +# external dependencies
> +anyhow = "1.0"
> +log = "0.4"
> +strum = { version = "0.26", features = ["derive"] }
> +
> +# proxmox dependencies
> +pve-api-types = "8.1"
> +
> diff --git a/Makefile b/Makefile
> new file mode 100644
> index 0000000..f89bb73
> --- /dev/null
> +++ b/Makefile
> @@ -0,0 +1,153 @@
> +# Shortcut for common operations:
> +
> +CRATES != echo pve-*/Cargo.toml | sed -e 's|/Cargo.toml||g'
> +
> +# By default we just run checks:
> +.PHONY: all
> +all: check
> +
> +.PHONY: deb
> +deb: $(foreach c,$(CRATES), $c-deb)
> +	echo $(foreach c,$(CRATES), $c-deb)
> +	lintian build/*.deb
> +
> +.PHONY: dsc
> +dsc: $(foreach c,$(CRATES), $c-dsc)
> +	echo $(foreach c,$(CRATES), $c-dsc)
> +	lintian build/*.dsc
> +
> +.PHONY: autopkgtest
> +autopkgtest: $(foreach c,$(CRATES), $c-autopkgtest)
> +
> +.PHONY: dinstall
> +dinstall:
> +	$(MAKE) clean
> +	$(MAKE) deb
> +	sudo -k dpkg -i build/librust-*.deb
> +
> +%-deb:
> +	./build.sh $*
> +	touch $@
> +
> +proxmox-oci-deb:
> +	TEST_CMD="fakeroot cargo test --all-features --all-targets --release" ./build.sh proxmox-oci
> +	touch $@
> +
> +%-dsc:
> +	BUILDCMD='dpkg-buildpackage -S -us -uc -d' NOTEST=1 ./build.sh $*
> +	touch $@
> +
> +%-autopkgtest:
> +	autopkgtest build/$* build/*.deb -- null
> +	touch $@
> +
> +.PHONY: list-packages
> +list-packages:
> +	@for p in $(CRATES); do \
> +		echo "librust-$$p-dev"; \
> +	done
> +
> +.PHONY: check
> +check:
> +	cargo test
> +
> +# Run the api-test server, serving the api-test/www/ subdir as 'www' dir over
> +# http:
> +.PHONY: apitest
> +apitest:
> +	cargo run -p api-test -- api-test/www/
> +
> +# Prints a diff between the current code and the one rustfmt would produce
> +.PHONY: fmt
> +fmt:
> +	cargo +nightly fmt -- --check
> +
> +# Doc without dependencies
> +.PHONY: doc
> +doc:
> +	cargo doc --no-deps
> +
> +.PHONY: clean
> +clean:
> +	cargo clean
> +	rm -rf build/
> +	rm -f -- *-deb *-dsc *-autopkgtest *.build *.buildinfo *.changes
> +
> +.PHONY: update
> +update:
> +	cargo update
> +
> +%-upload: %-deb
> +	cd build; \
> +	    dcmd --deb rust-$*_*.changes \
> +	    | grep -v '.changes$$' \
> +	    | tar -cf "$@.tar" -T-; \
> +	    cat "$@.tar" | ssh -X repoman@repo.proxmox.com upload --product devel --dist trixie
> +
> +%-install:
> +	rm -rf build/install/$*
> +	mkdir -p build/install/$*
> +	BUILDDIR=build/install/$* BUILDCMD=/usr/bin/true NOCONTROL=1 ./build.sh "$*" || true
> +	version="$$(dpkg-parsechangelog -l $*/debian/changelog -SVersion | sed -e 's/-.*//')"; \
> +	  install -m755 -Dd "$(DESTDIR)/usr/share/cargo/registry/$*-$${version}"; \
> +	  rm -rf "$(DESTDIR)/usr/share/cargo/registry/$*-$${version}"; \
> +	  mv "build/install/$*/$*" \
> +	    "$(DESTDIR)/usr/share/cargo/registry/$*-$${version}"; \
> +	  mv "$(DESTDIR)/usr/share/cargo/registry/$*-$${version}/debian/cargo-checksum.json" \
> +	    "$(DESTDIR)/usr/share/cargo/registry/$*-$${version}/.cargo-checksum.json"; \
> +	  rm -rf "$(DESTDIR)/usr/share/cargo/registry/$*-$${version}/debian" \
> +
> +.PHONY: install
> +install: $(foreach c,$(CRATES), $c-install)
> +
> +%-install-overlay: %-install
> +	version="$$(dpkg-parsechangelog -l $*/debian/changelog -SVersion | sed -e 's/-.*//')"; \
> +	  setfattr -n trusted.overlay.opaque -v y \
> +	    "$(DESTDIR)/usr/share/cargo/registry/$*-$${version}"
> +	install -m755 -Dd $(DESTDIR)/usr/lib/extension-release.d
> +	echo 'ID=_any' >$(DESTDIR)/usr/lib/extension-release.d/extension-release.$*
> +
> +.PHONY: install-overlay
> +install-overlay: $(foreach c,$(CRATES), $c-install-overlay)
> +
> +# To make sure a sysext *replaces* a crate, rather than "merging" with it, we
> +# need to be able to set the 'trusted.overlay.opaque' xattr. Since we cannot do
> +# this as a user, we utilize `fakeroot` which keeps track of this for us, and
> +# turn the final directory into an 'erofs' file system image.
> +#
> +# The reason is that if a crate gets changed like this:
> +#
> +#     old:
> +#         src/foo.rs
> +#     new:
> +#         src/foo/mod.rs
> +#
> +# if its /usr/share/cargo/registry/$crate-$version directory was not marked as
> +# "opaque", the merged file system would end up with both
> +#
> +#         src/foo.rs
> +#         src/foo/mod.rs
> +#
> +# together.
> +#
> +# See https://docs.kernel.org/filesystems/overlayfs.html
> +%-sysext:
> +	fakeroot $(MAKE) $*-sysext-do
> +%-sysext-do:
> +	rm -f extensions/$*.raw
> +	rm -rf build/sysext/$*
> +	rm -rf build/install/$*
> +	$(MAKE) DESTDIR=build/sysext/$* $*-install-overlay
> +	mkdir -p extensions
> +	mkfs.erofs extensions/$*.raw build/sysext/$*
> +
> +sysext:
> +	fakeroot $(MAKE) sysext-do
> +sysext-do:
> +	rm -f extensions/proxmox-workspace.raw
> +	[ -n "$(NOCLEAN)" ] || rm -rf build/sysext/workspace
> +	$(MAKE) DESTDIR=build/sysext/workspace $(foreach c,$(CRATES), $c-install)
> +	install -m755 -Dd build/sysext/workspace/usr/lib/extension-release.d
> +	echo 'ID=_any' >build/sysext/workspace/usr/lib/extension-release.d/extension-release.proxmox-workspace
> +	mkdir -p extensions
> +	mkfs.erofs extensions/proxmox-workspace.raw build/sysext/workspace
> diff --git a/build.sh b/build.sh
> new file mode 100755
> index 0000000..3c2293b
> --- /dev/null
> +++ b/build.sh
> @@ -0,0 +1,40 @@
> +#!/bin/sh
> +
> +set -e
> +
> +export CARGO=/usr/bin/cargo
> +export RUSTC=/usr/bin/rustc
> +
> +CRATE=$1
> +BUILDCMD=${BUILDCMD:-"dpkg-buildpackage -b -uc -us"}
> +BUILDDIR="${BUILDDIR:-"build"}"
> +TEST_CMD="${TEST_CMD:-"$CARGO test --all-features --all-targets --release"}"
> +
> +mkdir -p "${BUILDDIR}"
> +echo system >"${BUILDDIR}"/rust-toolchain
> +rm -rf "${BUILDDIR}/${CRATE}"
> +
> +CONTROL="$PWD/${CRATE}/debian/control"
> +
> +if [ -e "$CONTROL" ]; then
> +    # check but only warn, debcargo fails anyway if crates are missing
> +    dpkg-checkbuilddeps $PWD/${CRATE}/debian/control || true
> +    [ "x$NOCONTROL" = 'x' ] && rm -f "$PWD/${CRATE}/debian/control"
> +fi
> +
> +debcargo package \
> +    --config "$PWD/${CRATE}/debian/debcargo.toml" \
> +    --changelog-ready \
> +    --no-overlay-write-back \
> +    --directory "$PWD/"${BUILDDIR}"/${CRATE}" \
> +    "${CRATE}" \
> +    "$(dpkg-parsechangelog -l "${CRATE}/debian/changelog" -SVersion | sed -e 's/-.*//')"
> +
> +cd "${BUILDDIR}/${CRATE}"
> +rm -f debian/source/format.debcargo.hint
> +${BUILDCMD}
> +
> +# needs all crates build-dependencies, which can be more than what debcargo assembles.
> +[ "x$NOTEST" = "x" ] && $TEST_CMD
> +
> +[ "x$NOCONTROL" = "x" ] && cp debian/control "$CONTROL"
> diff --git a/bump.sh b/bump.sh
> new file mode 100755
> index 0000000..08ad119
> --- /dev/null
> +++ b/bump.sh
> @@ -0,0 +1,44 @@
> +#!/bin/bash
> +
> +package=$1
> +
> +if [[ -z "$package" ]]; then
> +	echo "USAGE:"
> +	echo -e "\t bump.sh <crate> [patch|minor|major|<version>]"
> +	echo ""
> +	echo "Defaults to bumping patch version by 1"
> +	exit 0
> +fi
> +
> +cargo_set_version="$(command -v cargo-set-version)"
> +if [[ -z "$cargo_set_version" || ! -x "$cargo_set_version" ]]; then
> +	echo 'bump.sh requires "cargo set-version", provided by "cargo-edit".'
> +	exit 1
> +fi
> +
> +if [[ ! -e "$package/Cargo.toml" ]]; then
> +	echo "Invalid crate '$package'"
> +	exit 1
> +fi
> +
> +version=$2
> +if [[ -z "$version" ]]; then
> +	version="patch"
> +fi
> +
> +case "$version" in
> +	patch|minor|major)
> +		bump="--bump"
> +		;;
> +	*)
> +		bump=
> +		;;
> +esac
> +
> +cargo_toml="$package/Cargo.toml"
> +changelog="$package/debian/changelog"
> +
> +cargo set-version -p "$package" $bump "$version"
> +version="$(cargo metadata --format-version=1 | jq ".packages[] | select(.name == \"$package\").version" | sed -e 's/\"//g')"
> +DEBFULLNAME="Proxmox Support Team" DEBEMAIL="support@proxmox.com" dch --no-conf --changelog "$changelog" --newversion "$version-1" --distribution stable
> +git commit --edit -sm "bump $package to $version-1" Cargo.toml "$cargo_toml" "$changelog"
> diff --git a/pve-qemu-server-pci/Cargo.toml b/pve-qemu-server-pci/Cargo.toml
> new file mode 100644
> index 0000000..d3f4401
> --- /dev/null
> +++ b/pve-qemu-server-pci/Cargo.toml
> @@ -0,0 +1,15 @@
> +[package]
> +name = "pve-qemu-server-pci"
> +version = "0.1.0"
> +authors.workspace = true
> +edition.workspace = true
> +license.workspace = true
> +repository.workspace = true
> +homepage.workspace = true
> +exclude.workspace = true
> +rust-version.workspace = true
> +
> +[dependencies]
> +strum.workspace = true
> +
> +pve-api-types.workspace = true
> diff --git a/pve-qemu-server-pci/debian/changelog b/pve-qemu-server-pci/debian/changelog
> new file mode 100644
> index 0000000..54847b9
> --- /dev/null
> +++ b/pve-qemu-server-pci/debian/changelog
> @@ -0,0 +1,5 @@
> +rust-pve-qemu-server-pci (0.1.0) trixie; urgency=medium
> +
> +  * initial release
> +
> + -- Proxmox Support Team <support@proxmox.com>  Tue, 01 Sep 2026 14:02:25 +0200
> diff --git a/pve-qemu-server-pci/debian/control b/pve-qemu-server-pci/debian/control
> new file mode 100644
> index 0000000..9edd0b9
> --- /dev/null
> +++ b/pve-qemu-server-pci/debian/control
> @@ -0,0 +1,36 @@
> +Source: rust-pve-qemu-server-pci
> +Section: rust
> +Priority: optional
> +Build-Depends: debhelper-compat (= 13),
> + dh-sequence-cargo
> +Build-Depends-Arch: cargo:native <!nocheck>,
> + rustc:native (>= 1.94) <!nocheck>,
> + libstd-rust-dev <!nocheck>,
> + librust-pve-api-types-8+default-dev (>= 8.1-~~) <!nocheck>,
> + librust-strum-0.26+default-dev <!nocheck>,
> + librust-strum-0.26+derive-dev <!nocheck>
> +Maintainer: Proxmox Support Team <support@proxmox.com>
> +Standards-Version: 4.7.2
> +Vcs-Git: git://git.proxmox.com/git/pve-qemu-server-rs.git
> +Vcs-Browser: https://git.proxmox.com/?p=pve-qemu-server-rs.git
> +Homepage: https://proxmox.com
> +X-Cargo-Crate: pve-qemu-server-pci
> +
> +Package: librust-pve-qemu-server-pci-dev
> +Architecture: any
> +Multi-Arch: same
> +Depends:
> + ${misc:Depends},
> + librust-pve-api-types-8+default-dev (>= 8.1-~~),
> + librust-strum-0.26+default-dev,
> + librust-strum-0.26+derive-dev
> +Provides:
> + librust-pve-qemu-server-pci+default-dev (= ${binary:Version}),
> + librust-pve-qemu-server-pci-0-dev (= ${binary:Version}),
> + librust-pve-qemu-server-pci-0+default-dev (= ${binary:Version}),
> + librust-pve-qemu-server-pci-0.1-dev (= ${binary:Version}),
> + librust-pve-qemu-server-pci-0.1+default-dev (= ${binary:Version}),
> + librust-pve-qemu-server-pci-0.1.0-dev (= ${binary:Version}),
> + librust-pve-qemu-server-pci-0.1.0+default-dev (= ${binary:Version})
> +Description: Rust crate "pve-qemu-server-pci" - Rust source code
> + Source code for Debianized Rust crate "pve-qemu-server-pci"
> diff --git a/pve-qemu-server-pci/debian/copyright b/pve-qemu-server-pci/debian/copyright
> new file mode 100644
> index 0000000..01138fa
> --- /dev/null
> +++ b/pve-qemu-server-pci/debian/copyright
> @@ -0,0 +1,18 @@
> +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
> +
> +Files:
> + *
> +Copyright: 2026 Proxmox Server Solutions GmbH <support@proxmox.com>
> +License: AGPL-3.0-or-later
> + This program is free software: you can redistribute it and/or modify it under
> + the terms of the GNU Affero General Public License as published by the Free
> + Software Foundation, either version 3 of the License, or (at your option) any
> + later version.
> + .
> + This program is distributed in the hope that it will be useful, but WITHOUT
> + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
> + FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
> + details.
> + .
> + You should have received a copy of the GNU Affero General Public License along
> + with this program. If not, see <https://www.gnu.org/licenses/>.
> diff --git a/pve-qemu-server-pci/debian/debcargo.toml b/pve-qemu-server-pci/debian/debcargo.toml
> new file mode 100644
> index 0000000..1d52b16
> --- /dev/null
> +++ b/pve-qemu-server-pci/debian/debcargo.toml
> @@ -0,0 +1,7 @@
> +overlay = "."
> +crate_src_path = ".."
> +maintainer = "Proxmox Support Team <support@proxmox.com>"
> +
> +[source]
> +vcs_git = "git://git.proxmox.com/git/pve-qemu-server-rs.git"
> +vcs_browser = "https://git.proxmox.com/?p=pve-qemu-server-rs.git"
> diff --git a/pve-qemu-server-pci/src/constants.rs b/pve-qemu-server-pci/src/constants.rs
> new file mode 100644
> index 0000000..ea6db21
> --- /dev/null
> +++ b/pve-qemu-server-pci/src/constants.rs
> @@ -0,0 +1,7 @@
> +//! Limits the layouts are built around.
> +
> +/// Number of slots on a PCI bus. Slot 0x00 is always taken by the bridge
> +/// itself, so only [`USABLE_SLOTS_PER_BUS`] of them can hold a device.
> +pub(crate) const SLOTS_PER_BUS: usize = 32;
> +pub(crate) const USABLE_SLOTS_PER_BUS: u8 = SLOTS_PER_BUS as u8 - 1;
> +pub(crate) const FUNCTIONS_PER_DEVICE: usize = 8;
> diff --git a/pve-qemu-server-pci/src/error.rs b/pve-qemu-server-pci/src/error.rs
> new file mode 100644
> index 0000000..5c4f7de
> --- /dev/null
> +++ b/pve-qemu-server-pci/src/error.rs
> @@ -0,0 +1,24 @@
> +use std::{error::Error, fmt::Display};
> +
> +use crate::Device;
> +
> +/// Errors that can occur while looking up a PCI address.
> +#[derive(Clone, Copy, Debug, PartialEq, Eq)]
> +#[non_exhaustive]
> +pub enum LayoutError {
> +    /// The given configuration id is not a device this crate knows about.
> +    InvalidConfigId,
> +    /// The device is not part of the layout it was looked up in.
> +    NoAddressFound(Device),
> +}
> +
> +impl Display for LayoutError {
> +    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
> +        match self {
> +            LayoutError::InvalidConfigId => write!(f, "invalid configuration id"),
> +            LayoutError::NoAddressFound(device) => write!(f, "address '{device}' not found"),
> +        }
> +    }
> +}
> +
> +impl Error for LayoutError {}
> diff --git a/pve-qemu-server-pci/src/hardware_config.rs b/pve-qemu-server-pci/src/hardware_config.rs
> new file mode 100644
> index 0000000..a348353
> --- /dev/null
> +++ b/pve-qemu-server-pci/src/hardware_config.rs
> @@ -0,0 +1,156 @@
> +use std::sync::Arc;
> +use std::sync::atomic::AtomicU16;
> +
> +use pve_api_types::{ClusterResourceHostArch as Arch, QemuConfigOstype, QemuConfigScsihw};
> +
> +#[derive(Default, Clone, Debug, PartialEq)]
> +pub enum Mainboard {
> +    #[default]
> +    I440fx,
> +    Q35,
> +    Virt,
> +}
> +
> +impl std::fmt::Display for Mainboard {
> +    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
> +        let text = match self {
> +            Mainboard::I440fx => "i440fx",
> +            Mainboard::Q35 => "q35",
> +            Mainboard::Virt => "virt",
> +        };
> +        f.write_str(text)
> +    }
> +}
> +
> +impl Mainboard {
> +    pub fn has_pcie(&self) -> bool {
> +        match self {
> +            Mainboard::I440fx => false,
> +            Mainboard::Q35 | Mainboard::Virt => true,
> +        }
> +    }
> +}
> +
> +/// The parts of a guest configuration that influence which PCI layout is used
> +/// and which quirks have to be applied to it.
> +///
> +/// This is deliberately not the full guest config: it only carries what the
> +/// address generation needs, so that callers can build it from whatever
> +/// configuration representation they have.
> +#[derive(Default, Clone, Debug)]
> +pub struct HardwareConfig {
> +    pub arch: Arch,
> +    pub mainboard: Mainboard,
> +    pub scsihw: Option<QemuConfigScsihw>,
> +    /// Highest `scsihw` controller index in use. Controllers from index 2 on
> +    /// live on `pci.4`, so this decides whether that bridge is needed.
> +    pub max_scsihw: u8,
> +    pub legacy_igd: bool,
> +    pub ostype: Option<QemuConfigOstype>,
> +    /// QEMU machine version as `(major, minor, pve)`, where the PVE specific
> +    /// revision is optional.
> +    pub machine_version: (u16, u16, Option<u16>),
> +    /// The recorded required `+pveX` version
> +    pub required_pve_version: Arc<AtomicU16>,
> +}
> +
> +impl HardwareConfig {
> +    /// Builds a [`HardwareConfig`] from the raw configuration values.
> +    ///
> +    /// Unparsable `arch`, `scsihw` and `ostype` values are not an error here:
> +    /// `arch` falls back to x86_64 and the other two to `None`, which is the
> +    /// same defaulting the Perl implementation does.

But does it make sense to act like this *here*?
I'm thinking it may make more sense for that to happen at the `pve-rs`
side, to not let this creep in any further in the future?

> +    #[allow(clippy::too_many_arguments)]
> +    pub fn new(
> +        arch: &str,
> +        machine_type: String,
> +        scsihw: Option<String>,
> +        max_scsihw: u8,
> +        legacy_igd: bool,
> +        ostype: Option<String>,
> +        major: u16,
> +        minor: u16,
> +        pve: Option<u16>,
> +    ) -> Self {
> +        let arch = match arch.parse().unwrap_or(Arch::X8664) {
> +            Arch::UnknownEnumValue(_) => Arch::X8664,
> +            other => other,
> +        };
> +        let scsihw = scsihw.and_then(|hw| hw.parse().ok());
> +        let ostype = ostype.and_then(|ostype| ostype.parse().ok());
> +
> +        let mainboard = match arch {
> +            Arch::Aarch64 => Mainboard::Virt,
> +            Arch::X8664 if machine_type.contains("q35") => Mainboard::Q35,
> +            Arch::X8664 | Arch::UnknownEnumValue(_) => Mainboard::I440fx,
> +        };
> +        Self {
> +            arch,
> +            mainboard,
> +            scsihw,
> +            max_scsihw,
> +            legacy_igd,
> +            ostype,
> +            machine_version: (major, minor, pve),
> +            required_pve_version: Arc::new(AtomicU16::new(0)),
> +        }
> +    }
> +
> +    /// Whether the guest's root bus is PCIe. True for q35 machines and for
> +    /// aarch64, which only has a PCIe host bridge.
> +    pub fn has_pcie(&self) -> bool {
> +        self.mainboard.has_pcie()
> +    }
> +
> +    /// Returns if the mainboard is a Q35 variant.
> +    pub fn is_q35(&self) -> bool {
> +        self.mainboard == Mainboard::Q35
> +    }
> +
> +    /// Whether each SCSI disk gets its own controller, which needs the extra
> +    /// `pci.3` bridge.
> +    pub fn virtio_scsi_single(&self) -> bool {
> +        self.scsihw == Some(QemuConfigScsihw::VirtioScsiSingle)
> +    }
> +
> +    /// Whether the machine version is at least `major.minor.pve`.
> +    ///
> +    /// A machine without a PVE revision counts as '+pve0'.
> +    pub fn version_at_least(&self, major: u16, minor: u16, pve: Option<u16>) -> bool {
> +        match (self.machine_version.0, self.machine_version.1).cmp(&(major, minor)) {
> +            std::cmp::Ordering::Less => false,
> +            std::cmp::Ordering::Greater => true,
> +            std::cmp::Ordering::Equal => {
> +                let ours = self.machine_version.2.unwrap_or(0);
> +                let pve = pve.unwrap_or(0);
> +                let ok = ours >= pve;
> +                if ok && pve > 0 {
> +                    self.required_pve_version
> +                        .fetch_max(pve, std::sync::atomic::Ordering::SeqCst);

This can also be `Ordering::Relaxed`.

> +                }
> +                ok
> +            }
> +        }
> +    }
> +
> +    /// Returns the version number for Windows OS types or 0 for other.
> +    ///
> +    /// Windows XP era OS types are represented by `5`, and Vista era ones by `6`.
> +    pub fn windows_version(&self) -> u8 {
> +        match self.ostype.unwrap_or(QemuConfigOstype::Other) {
> +            QemuConfigOstype::Wxp | QemuConfigOstype::W2k | QemuConfigOstype::W2k3 => 5,
> +            QemuConfigOstype::W2k8 | QemuConfigOstype::Wvista => 6,
> +            QemuConfigOstype::Win7 => 7,
> +            QemuConfigOstype::Win8 => 8,
> +            QemuConfigOstype::Win10 => 10,
> +            QemuConfigOstype::Win11 => 11,
> +            _ => 0,
> +        }
> +    }
> +
> +    /// Returns the recorded required `+pveX` version.
> +    pub fn required_pve_version(&self) -> u16 {
> +        self.required_pve_version
> +            .load(std::sync::atomic::Ordering::Relaxed)
> +    }
> +}
> diff --git a/pve-qemu-server-pci/src/layout/legacy.rs b/pve-qemu-server-pci/src/layout/legacy.rs
> new file mode 100644
> index 0000000..e6693ca
> --- /dev/null
> +++ b/pve-qemu-server-pci/src/layout/legacy.rs
> @@ -0,0 +1,628 @@
> +use crate::constants::SLOTS_PER_BUS;
> +use crate::hardware_config::Mainboard;
> +use crate::layout::bridge_slots;
> +use crate::{Bus, Device, Function, HardwareConfig, Layout, LayoutError, PciAddress, Slot};
> +
> +const PCI_1: [Slot; SLOTS_PER_BUS] = bridge_slots(&[
> +    single_device!(Net(6)),
> +    single_device!(Net(7)),
> +    single_device!(Net(8)),
> +    single_device!(Net(9)),
> +    single_device!(Net(10)),
> +    single_device!(Net(11)),
> +    single_device!(Net(12)),
> +    single_device!(Net(13)),
> +    single_device!(Net(14)),
> +    single_device!(Net(15)),
> +    single_device!(Net(16)),
> +    single_device!(Net(17)),
> +    single_device!(Net(18)),
> +    single_device!(Net(19)),
> +    single_device!(Net(20)),
> +    single_device!(Net(21)),
> +    single_device!(Net(22)),
> +    single_device!(Net(23)),
> +    single_device!(Net(24)),
> +    single_device!(Net(25)),
> +    single_device!(Net(26)),
> +    single_device!(Net(27)),
> +    single_device!(Net(28)),
> +    single_device!(Net(29)),
> +    single_device!(Net(30)),
> +    single_device!(Net(31)),
> +    single_device!(XhciController(0)),
> +    Slot::Single(Function::FixedBridge(Bus::Pci(4), &PCI_4)),
> +    single_device!(Rng),
> +    single_device!(Bridge(Bus::Pci(99))), // same as pci.2 but for igd
> +]);
> +
> +const PCI_2: [Slot; SLOTS_PER_BUS] = bridge_slots(&[
> +    single_device!(VirtioBlk(6)),
> +    single_device!(VirtioBlk(7)),
> +    single_device!(VirtioBlk(8)),
> +    single_device!(VirtioBlk(9)),
> +    single_device!(VirtioBlk(10)),
> +    single_device!(VirtioBlk(11)),
> +    single_device!(VirtioBlk(12)),
> +    single_device!(VirtioBlk(13)),
> +    single_device!(VirtioBlk(14)),
> +    single_device!(VirtioBlk(15)),
> +    single_device!(Ivshmem),
> +    single_device!(Audio),
> +    single_device!(Hostpci(4)),
> +    single_device!(Hostpci(5)),
> +    single_device!(Hostpci(6)),
> +    single_device!(Hostpci(7)),
> +    single_device!(Hostpci(8)),
> +    single_device!(Hostpci(9)),
> +    single_device!(Hostpci(10)),
> +    single_device!(Hostpci(11)),
> +    single_device!(Hostpci(12)),
> +    single_device!(Hostpci(13)),
> +    single_device!(Hostpci(14)),
> +    single_device!(Hostpci(15)),
> +]);
> +
> +const PCI_3: [Slot; SLOTS_PER_BUS] = bridge_slots(&[
> +    single_device!(ScsiController(0)),
> +    single_device!(ScsiController(1)),
> +    single_device!(ScsiController(2)),
> +    single_device!(ScsiController(3)),
> +    single_device!(ScsiController(4)),
> +    single_device!(ScsiController(5)),
> +    single_device!(ScsiController(6)),
> +    single_device!(ScsiController(7)),
> +    single_device!(ScsiController(8)),
> +    single_device!(ScsiController(9)),
> +    single_device!(ScsiController(10)),
> +    single_device!(ScsiController(11)),
> +    single_device!(ScsiController(12)),
> +    single_device!(ScsiController(13)),
> +    single_device!(ScsiController(14)),
> +    single_device!(ScsiController(15)),
> +    single_device!(ScsiController(16)),
> +    single_device!(ScsiController(17)),
> +    single_device!(ScsiController(18)),
> +    single_device!(ScsiController(19)),
> +    single_device!(ScsiController(20)),
> +    single_device!(ScsiController(21)),
> +    single_device!(ScsiController(22)),
> +    single_device!(ScsiController(23)),
> +    single_device!(ScsiController(24)),
> +    single_device!(ScsiController(25)),
> +    single_device!(ScsiController(26)),
> +    single_device!(ScsiController(27)),
> +    single_device!(ScsiController(28)),
> +    single_device!(ScsiController(29)),
> +    single_device!(ScsiController(30)),
> +]);
> +
> +const PCI_4: [Slot; SLOTS_PER_BUS] = bridge_slots(&[
> +    single_device!(ScsiController(2)),
> +    single_device!(ScsiController(3)),
> +    single_device!(ScsiController(4)),
> +]);
> +
> +/// Builds the PCI layout of a guest.
> +///
> +/// With `virtio_scsi_single` the SCSI controllers move off the root bus onto
> +/// their own `pci.3` bridge, which is why this cannot be a constant.
> +pub(crate) fn get_legacy_pci_layout<'a>(pcie: bool, virtio_scsi_single: bool) -> Layout<'a> {

A rogue lifetime appears.

Drop it and return `Layout<'static>`.

> +    Layout {
> +        pcie,
> +        root: [
> +            Slot::Reserved,
> +            single_device!(Piix3Controller), // actually on 0x01.2 but the function number is added by QemuServer/USB.pm
> +            single_device!(Vga(0)),
> +            single_device!(Balloon),
> +            single_device!(Watchdog),
> +            if virtio_scsi_single {
> +                Slot::Single(Function::FixedBridge(Bus::Pci(3), &PCI_3))
> +            } else {
> +                single_device!(ScsiController(0))
> +            },
> +            single_device!(ScsiController(1)),
> +            single_device!(Ahci),
> +            single_device!(GuestAgent),
> +            single_device!(SpiceSerial),
> +            single_device!(VirtioBlk(0)),
> +            single_device!(VirtioBlk(1)),
> +            single_device!(VirtioBlk(2)),
> +            single_device!(VirtioBlk(3)),
> +            single_device!(VirtioBlk(4)),
> +            single_device!(VirtioBlk(5)),
> +            single_device!(Hostpci(0)),
> +            single_device!(Hostpci(1)),
> +            single_device!(Net(0)),
> +            single_device!(Net(1)),
> +            single_device!(Net(2)),
> +            single_device!(Net(3)),
> +            single_device!(Net(4)),
> +            single_device!(Net(5)),
> +            single_device!(Vga(1)),
> +            single_device!(Vga(2)),
> +            single_device!(Vga(3)),
> +            single_device!(Hostpci(2)),
> +            single_device!(Hostpci(3)),
> +            Slot::Reserved, // usb-host (pve-usb.cfg)
> +            Slot::Single(Function::FixedBridge(Bus::Pci(1), &PCI_1)),
> +            Slot::Single(Function::FixedBridge(Bus::Pci(2), &PCI_2)),
> +        ],
> +    }
> +}
> +
> +/// The PCIe layout of a q35 guest.
> +///
> +/// Only contains the devices on the PCIe bus itself, not the ones nested on
> +/// the PCI bridges below it.
> +#[expect(deprecated)]
> +pub(crate) const LEGACY_PCIE_LAYOUT: Layout = Layout {
> +    pcie: true,
> +    root: [
> +        Slot::Reserved,
> +        single_device!(Vga(0)),
> +        Slot::Unused,
> +        Slot::Unused,
> +        Slot::Unused,
> +        Slot::Unused,
> +        Slot::Unused,
> +        Slot::Unused,
> +        Slot::Unused,
> +        Slot::Unused,
> +        Slot::Unused,
> +        Slot::Unused,
> +        Slot::Unused,
> +        Slot::Unused,
> +        Slot::Unused,
> +        Slot::Unused,
> +        Slot::Multi(&[
> +            Function::RootPort(Bus::LegacyRootPort(5), Device::Hostpcie(4)),
> +            Function::RootPort(Bus::LegacyRootPort(6), Device::Hostpcie(5)),
> +            Function::RootPort(Bus::LegacyRootPort(7), Device::Hostpcie(6)),
> +            Function::RootPort(Bus::LegacyRootPort(8), Device::Hostpcie(7)),
> +            Function::RootPort(Bus::LegacyRootPort(9), Device::Hostpcie(8)),
> +            Function::RootPort(Bus::LegacyRootPort(10), Device::Hostpcie(9)),
> +            Function::RootPort(Bus::LegacyRootPort(11), Device::Hostpcie(10)),
> +            Function::RootPort(Bus::LegacyRootPort(12), Device::Hostpcie(11)),
> +        ]),
> +        Slot::Multi(&[
> +            Function::RootPort(Bus::LegacyRootPort(13), Device::Hostpcie(12)),
> +            Function::RootPort(Bus::LegacyRootPort(14), Device::Hostpcie(13)),
> +            Function::RootPort(Bus::LegacyRootPort(15), Device::Hostpcie(14)),
> +            Function::RootPort(Bus::LegacyRootPort(16), Device::Hostpcie(15)),
> +            Function::Unused,
> +            Function::Unused,
> +            Function::Unused,
> +            Function::Unused,
> +        ]),
> +        Slot::Unused,
> +        Slot::Unused,
> +        single_device!(Ivshmem),
> +        Slot::Unused,
> +        Slot::Unused,
> +        Slot::Unused,
> +        Slot::Unused,
> +        Slot::Unused,
> +        Slot::Multi(&[
> +            Function::Device(Device::Ich9Uhci(4)),
> +            Function::Device(Device::Ich9Uhci(5)),
> +            Function::Device(Device::Ich9Uhci(6)),
> +            Function::Unused,
> +            Function::Unused,
> +            Function::Unused,
> +            Function::Unused,
> +            Function::Device(Device::Ich9Ehci(2)),
> +        ]),
> +        single_device!(Ich9Audio),
> +        Slot::Multi(&[
> +            Function::RootPort(Bus::LegacyRootPort(1), Device::Hostpcie(0)),
> +            Function::RootPort(Bus::LegacyRootPort(2), Device::Hostpcie(1)),
> +            Function::RootPort(Bus::LegacyRootPort(3), Device::Hostpcie(2)),
> +            Function::RootPort(Bus::LegacyRootPort(4), Device::Hostpcie(3)),
> +            Function::Unused,
> +            Function::Unused,
> +            Function::Unused,
> +            Function::Unused,
> +        ]),
> +        Slot::Multi(&[
> +            Function::Device(Device::Ich9Uhci(1)),
> +            Function::Device(Device::Ich9Uhci(2)),
> +            Function::Device(Device::Ich9Uhci(3)),
> +            Function::Unused,
> +            Function::Unused,
> +            Function::Unused,
> +            Function::Unused,
> +            Function::Device(Device::Ich9Ehci(1)),
> +        ]),
> +        Slot::Single(Function::FixedBridge(
> +            Bus::Pcidmi,
> +            &bridge_slots(&[
> +                single_device!(Bridge(Bus::Pci(0))),
> +                single_device!(Bridge(Bus::Pci(1))),
> +                single_device!(Bridge(Bus::Pci(2))),
> +                single_device!(Bridge(Bus::Pci(3))),
> +            ]),
> +        )),
> +        Slot::Reserved, // ICH9 LPC, SATA and SMBus controllers
> +    ],
> +};
> +
> +/// The PCIe layout for Windows 7 guests, whose driver cannot cope with
> +/// passed through devices behind root ports.
> +///
> +/// Only contains the `hostpci` slots, everything else is handled by
> +/// [`LEGACY_PCIE_LAYOUT`].
> +pub(crate) const LEGACY_PCIE_LAYOUT_WIN7: Layout = Layout {
> +    pcie: true,
> +    root: [
> +        Slot::Reserved,
> +        Slot::Reserved,
> +        Slot::Unused,
> +        Slot::Unused,
> +        Slot::Unused,
> +        Slot::Unused,
> +        Slot::Unused,
> +        Slot::Unused,
> +        Slot::Unused,
> +        single_device!(Hostpcie(4)),
> +        single_device!(Hostpcie(5)),
> +        single_device!(Hostpcie(6)),
> +        single_device!(Hostpcie(7)),
> +        single_device!(Hostpcie(8)),
> +        single_device!(Hostpcie(9)),
> +        single_device!(Hostpcie(10)),
> +        single_device!(Hostpcie(0)),
> +        single_device!(Hostpcie(1)),
> +        single_device!(Hostpcie(2)),
> +        single_device!(Hostpcie(3)),
> +        Slot::Reserved,
> +        single_device!(Hostpcie(11)),
> +        single_device!(Hostpcie(12)),
> +        single_device!(Hostpcie(13)),
> +        single_device!(Hostpcie(14)),
> +        single_device!(Hostpcie(15)),
> +        Slot::Unused,
> +        Slot::Unused,
> +        Slot::Reserved,
> +        Slot::Unused,
> +        Slot::Unused,
> +        Slot::Unused,
> +    ],
> +};
> +
> +/// Whether a (mapped) id asks for the `virtio-scsi-single` variant of the
> +/// layout, where every SCSI disk gets its own controller on `pci.3`.
> +fn is_virtio_scsi_single_id(id: &str) -> bool {
> +    id.starts_with("virtioscsi")
> +}
> +
> +/// Maps (conflicting) legacy PCI IDs to ones we can represent here.
> +fn map_legacy_pci_id(original: &str) -> &str {
> +    // we can't request  scsihwX and virtioscsiX at the same time,
> +    // and that's the only thing living on 'pci.3'
> +    if original == "pci.3" {
> +        return "scsihw0";
> +    }
> +
> +    //  the legacy-igd device must be the same as the primary adapter
> +    //  and requires  vga=none
> +    if original == "legacy-igd" {
> +        return "vga";
> +    }
> +
> +    //  when using legacy-igd, pci.2 is on a different address due to
> +    //  driver constraints
> +    if original == "pci.2-igd" {
> +        return "pci.99";
> +    }

nit: might be nicer to read as:

    match original {
        "a" => "b",
        "x" => "y",
        o => o,
    }

> +
> +    original
> +}
> +
> +/// Looks up the PCI bridge a device sits on, see
> +/// [`crate::pci_bridge_for_device`].
> +pub(crate) fn pci_bridge_for_device(id: &str) -> Option<Bus> {
> +    let new_id = map_legacy_pci_id(id);
> +    // the root bus is never a bridge we have to add ourselves, so it does not
> +    // matter whether it is PCI or PCIe here
> +    let layout = get_legacy_pci_layout(false, is_virtio_scsi_single_id(new_id));
> +    let device = new_id.parse().ok()?;
> +    let addr = layout.find_device(&device).ok()?;
> +    Some(addr.bus)
> +}
> +
> +/// Returns if the caller wants a PCIE address instead of a PCI one based on the id.
> +fn is_pcie_id(id: &str) -> bool {
> +    id.starts_with("hostpcie") || id == "vga" || id == "ivshmem"
> +}
> +
> +pub(crate) fn address(hw: &HardwareConfig, id: &str) -> Result<PciAddress, LayoutError> {
> +    let (layout, new_id) = match hw.mainboard {
> +        Mainboard::Q35 if is_pcie_id(id) => {
> +            let layout = if hw.windows_version() == 7 && id.starts_with("hostpcie") {
> +                LEGACY_PCIE_LAYOUT_WIN7
> +            } else {
> +                LEGACY_PCIE_LAYOUT
> +            };
> +            (layout, id)
> +        }
> +        Mainboard::Q35 | Mainboard::Virt | Mainboard::I440fx => {
> +            let new_id = map_legacy_pci_id(id);
> +            let layout = get_legacy_pci_layout(
> +                hw.mainboard == Mainboard::Virt,
> +                is_virtio_scsi_single_id(id),
> +            );
> +            (layout, new_id)
> +        }
> +    };
> +
> +    layout.find_device(&new_id.parse()?)
> +}
> +
> +#[cfg(test)]
> +pub(crate) mod test {
> +    use std::collections::HashSet;
> +
> +    use super::{LEGACY_PCIE_LAYOUT, get_legacy_pci_layout, pci_bridge_for_device};
> +    use crate::hardware_config::Mainboard;
> +    use crate::layout::legacy::is_pcie_id;
> +    use crate::{Bus, Device, HardwareConfig, Layout, PciAddress};
> +
> +    pub const LEGACY_ADDRS: [(&str, u8, u8); 121] = [
> +        ("ahci0", 0, 7),
> +        ("audio0", 2, 12),
> +        ("balloon0", 0, 3),
> +        ("ehci", 0, 1),
> +        ("hostpci0", 0, 16),
> +        ("hostpci1", 0, 17),
> +        ("hostpci2", 0, 27),
> +        ("hostpci3", 0, 28),
> +        ("hostpci4", 2, 13),
> +        ("hostpci5", 2, 14),
> +        ("hostpci6", 2, 15),
> +        ("hostpci7", 2, 16),
> +        ("hostpci8", 2, 17),
> +        ("hostpci9", 2, 18),
> +        ("hostpci10", 2, 19),
> +        ("hostpci11", 2, 20),
> +        ("hostpci12", 2, 21),
> +        ("hostpci13", 2, 22),
> +        ("hostpci14", 2, 23),
> +        ("hostpci15", 2, 24),
> +        ("ivshmem", 2, 11),
> +        ("legacy-igd", 0, 2),
> +        ("net0", 0, 18),
> +        ("net1", 0, 19),
> +        ("net2", 0, 20),
> +        ("net3", 0, 21),
> +        ("net4", 0, 22),
> +        ("net5", 0, 23),
> +        ("net6", 1, 1),
> +        ("net7", 1, 2),
> +        ("net8", 1, 3),
> +        ("net9", 1, 4),
> +        ("net10", 1, 5),
> +        ("net11", 1, 6),
> +        ("net12", 1, 7),
> +        ("net13", 1, 8),
> +        ("net14", 1, 9),
> +        ("net15", 1, 10),
> +        ("net16", 1, 11),
> +        ("net17", 1, 12),
> +        ("net18", 1, 13),
> +        ("net19", 1, 14),
> +        ("net20", 1, 15),
> +        ("net21", 1, 16),
> +        ("net22", 1, 17),
> +        ("net23", 1, 18),
> +        ("net24", 1, 19),
> +        ("net25", 1, 20),
> +        ("net26", 1, 21),
> +        ("net27", 1, 22),
> +        ("net28", 1, 23),
> +        ("net29", 1, 24),
> +        ("net30", 1, 25),
> +        ("net31", 1, 26),
> +        ("pci.1", 0, 30),
> +        ("pci.2", 0, 31),
> +        ("pci.2-igd", 1, 30),
> +        ("pci.3", 0, 5),
> +        ("pci.4", 1, 28),
> +        ("piix3", 0, 1),
> +        ("qga0", 0, 8),
> +        ("rng0", 1, 29),
> +        ("scsihw0", 0, 5),
> +        ("scsihw1", 0, 6),
> +        ("scsihw2", 4, 1),
> +        ("scsihw3", 4, 2),
> +        ("scsihw4", 4, 3),
> +        ("spice", 0, 9),
> +        ("vga", 0, 2),
> +        ("vga1", 0, 24),
> +        ("vga2", 0, 25),
> +        ("vga3", 0, 26),
> +        ("virtio0", 0, 10),
> +        ("virtio1", 0, 11),
> +        ("virtio2", 0, 12),
> +        ("virtio3", 0, 13),
> +        ("virtio4", 0, 14),
> +        ("virtio5", 0, 15),
> +        ("virtio6", 2, 1),
> +        ("virtio7", 2, 2),
> +        ("virtio8", 2, 3),
> +        ("virtio9", 2, 4),
> +        ("virtio10", 2, 5),
> +        ("virtio11", 2, 6),
> +        ("virtio12", 2, 7),
> +        ("virtio13", 2, 8),
> +        ("virtio14", 2, 9),
> +        ("virtio15", 2, 10),
> +        ("virtioscsi0", 3, 1),
> +        ("virtioscsi1", 3, 2),
> +        ("virtioscsi2", 3, 3),
> +        ("virtioscsi3", 3, 4),
> +        ("virtioscsi4", 3, 5),
> +        ("virtioscsi5", 3, 6),
> +        ("virtioscsi6", 3, 7),
> +        ("virtioscsi7", 3, 8),
> +        ("virtioscsi8", 3, 9),
> +        ("virtioscsi9", 3, 10),
> +        ("virtioscsi10", 3, 11),
> +        ("virtioscsi11", 3, 12),
> +        ("virtioscsi12", 3, 13),
> +        ("virtioscsi13", 3, 14),
> +        ("virtioscsi14", 3, 15),
> +        ("virtioscsi15", 3, 16),
> +        ("virtioscsi16", 3, 17),
> +        ("virtioscsi17", 3, 18),
> +        ("virtioscsi18", 3, 19),
> +        ("virtioscsi19", 3, 20),
> +        ("virtioscsi20", 3, 21),
> +        ("virtioscsi21", 3, 22),
> +        ("virtioscsi22", 3, 23),
> +        ("virtioscsi23", 3, 24),
> +        ("virtioscsi24", 3, 25),
> +        ("virtioscsi25", 3, 26),
> +        ("virtioscsi26", 3, 27),
> +        ("virtioscsi27", 3, 28),
> +        ("virtioscsi28", 3, 29),
> +        ("virtioscsi29", 3, 30),
> +        ("virtioscsi30", 3, 31),
> +        ("watchdog", 0, 4),
> +        ("xhci", 1, 27),
> +    ];
> +
> +    fn pci_addr(pcie: bool, bus: u8, device: u8) -> PciAddress {
> +        PciAddress {
> +            bus: if pcie { Bus::Pcie(bus) } else { Bus::Pci(bus) },
> +            device,
> +            function: 0,
> +        }
> +    }
> +
> +    #[test]
> +    /// Test string id to addr, big overlap with config2command tests in qemu-server
> +    fn test_pci_addr_maps() {
> +        // copied and sorted from src/PVE/QemuServer/PCI.pm
> +
> +        let i440fx = HardwareConfig::default();
> +        let q35 = HardwareConfig {
> +            mainboard: Mainboard::Q35,
> +            ..Default::default()
> +        };
> +        let arm = HardwareConfig {
> +            arch: pve_api_types::ClusterResourceHostArch::Aarch64,
> +            mainboard: Mainboard::Virt,
> +            ..Default::default()
> +        };
> +        for (input, bus, device) in LEGACY_ADDRS {
> +            let addr =
> +                crate::address(&i440fx, input).unwrap_or_else(|_| panic!("{input} not found"));
> +            let expected = pci_addr(false, bus, device);
> +            assert_eq!(addr, expected, "tested {input} on i440fx");
> +
> +            if !is_pcie_id(input) {
> +                let addr =
> +                    crate::address(&q35, input).unwrap_or_else(|_| panic!("{input} not found"));
> +                let expected = pci_addr(false, bus, device);
> +                assert_eq!(addr, expected, "tested {input} on q35");
> +            }
> +
> +            // arm is on pcie
> +            let addr = crate::address(&arm, input).unwrap_or_else(|_| panic!("{input} not found"));
> +            let expected = pci_addr(bus == 0, bus, device);
> +            assert_eq!(addr, expected, "tested {input} on virt");
> +        }
> +    }
> +
> +    #[test]
> +    /// Test string id to addr, big overlap with config2command tests in qemu-server
> +    fn test_pcie_addr_maps() {
> +        // copied and sorted from src/PVE/QemuServer/PCI.pm
> +        let tests = [
> +            ("hostpcie0", true, 1),
> +            ("hostpcie1", true, 2),
> +            ("hostpcie2", true, 3),
> +            ("hostpcie3", true, 4),
> +            ("hostpcie4", true, 5),
> +            ("hostpcie5", true, 6),
> +            ("hostpcie6", true, 7),
> +            ("hostpcie7", true, 8),
> +            ("hostpcie8", true, 9),
> +            ("hostpcie9", true, 10),
> +            ("hostpcie10", true, 11),
> +            ("hostpcie11", true, 12),
> +            ("hostpcie12", true, 13),
> +            ("hostpcie13", true, 14),
> +            ("hostpcie14", true, 15),
> +            ("hostpcie15", true, 16),
> +            ("ivshmem", false, 20),
> +            ("vga", false, 1),
> +        ];
> +
> +        let pcie_machine = HardwareConfig {
> +            mainboard: Mainboard::Q35,
> +            ..Default::default()
> +        };
> +        for (input, rp, num) in tests {
> +            let addr = crate::address(&pcie_machine, input)
> +                .unwrap_or_else(|_| panic!("device not found: {input}"));
> +            let (bus, device) = if rp {
> +                #[expect(deprecated)]
> +                (Bus::LegacyRootPort(num), 0)
> +            } else {
> +                (Bus::Pcie(0), num)
> +            };
> +            let expected = PciAddress::new(bus, device, 0);
> +            assert_eq!(addr, expected, "tested {input}");
> +        }
> +    }
> +
> +    /// A bus can only be used once the bridge or root port creating it has
> +    /// been yielded, otherwise a command line built in iteration order would
> +    /// reference a bus QEMU does not know yet.
> +    fn assert_buses_before_devices(layout: &Layout) {
> +        let mut buses = HashSet::from([if layout.pcie {
> +            Bus::Pcie(0)
> +        } else {
> +            Bus::Pci(0)
> +        }]);
> +
> +        for used in layout.iter() {
> +            assert!(
> +                buses.contains(&used.addr.bus),
> +                "{:?} is on {:?}, which was not created yet",
> +                used.device,
> +                used.addr.bus
> +            );
> +            match used.device {
> +                Device::Bridge(bus) => buses.insert(bus),
> +                Device::RootPort(bus) => buses.insert(bus),
> +                _ => continue,
> +            };
> +        }
> +    }
> +
> +    #[test]
> +    fn test_bus_order() {
> +        assert_buses_before_devices(&LEGACY_PCIE_LAYOUT);
> +        assert_buses_before_devices(&get_legacy_pci_layout(false, false));
> +        assert_buses_before_devices(&get_legacy_pci_layout(false, true));
> +        assert_buses_before_devices(&get_legacy_pci_layout(true, true));
> +    }
> +
> +    #[test]
> +    /// The bus of a device has to agree with the address it is given. Note
> +    /// that the root bus is reported as bus zero, not as no bus at all.
> +    fn test_pci_bridge_for_device() {
> +        for (input, bus, _) in LEGACY_ADDRS {
> +            assert_eq!(
> +                pci_bridge_for_device(input),
> +                Some(Bus::Pci(bus)),
> +                "for {input}"
> +            );
> +        }
> +
> +        assert_eq!(pci_bridge_for_device("no-such-device"), None);
> +    }
> +}
> diff --git a/pve-qemu-server-pci/src/layout/mod.rs b/pve-qemu-server-pci/src/layout/mod.rs
> new file mode 100644
> index 0000000..5439c17
> --- /dev/null
> +++ b/pve-qemu-server-pci/src/layout/mod.rs
> @@ -0,0 +1,48 @@
> +use crate::Slot;
> +use crate::constants::SLOTS_PER_BUS;
> +
> +// local helper macro to make the lines shorter
> +macro_rules! single_device {
> +    ($i:ident ( $e:expr )) => {
> +        $crate::Slot::Single($crate::Function::Device($crate::Device::$i($e)))
> +    };
> +    ($i:ident) => {
> +        $crate::Slot::Single($crate::Function::Device($crate::Device::$i))
> +    };
> +}
> +
> +pub(crate) mod legacy;
> +
> +/// Constructs a correctly sized list of bridge slots, starting at address 0x01
> +/// because 0x00 is always taken by the bridge itself.
> +///
> +/// Panics if more slots than fit are passed in. As this is a `const fn` used
> +/// from constants only, that turns into a compile time error.
> +pub(crate) const fn bridge_slots<'a>(slots: &'a [Slot<'a>]) -> [Slot<'a>; SLOTS_PER_BUS] {
> +    const PLACEHOLDER: Slot = Slot::Unused;
> +    let mut out = [PLACEHOLDER; SLOTS_PER_BUS];
> +    if slots.len() > SLOTS_PER_BUS - 1 {
> +        panic!("too many devices on bridge");
> +    }
> +    out[0] = Slot::Reserved;
> +    let mut i = 1;
> +    while i < SLOTS_PER_BUS && i - 1 < slots.len() {
> +        out[i] = slots[i - 1];
> +        i += 1;
> +    }
> +    out
> +}
> +
> +#[cfg(test)]
> +mod test {
> +    use crate::Slot;
> +    use crate::constants::SLOTS_PER_BUS;
> +    use crate::layout::bridge_slots;
> +
> +    #[test]
> +    fn test_bridge_slots() {
> +        const BRIDGE: [Slot; SLOTS_PER_BUS] =
> +            bridge_slots(&[Slot::Reserved, single_device!(Viommu)]);
> +        assert_eq!(BRIDGE[1], Slot::Reserved);
> +    }
> +}
> diff --git a/pve-qemu-server-pci/src/lib.rs b/pve-qemu-server-pci/src/lib.rs
> new file mode 100644
> index 0000000..43a3886
> --- /dev/null
> +++ b/pve-qemu-server-pci/src/lib.rs
> @@ -0,0 +1,33 @@
> +//! Generation of the PCI and PCIe addresses of the devices of a PVE guest.
> +//!
> +//! Which address a device gets is part of the guest's ABI: moving a device
> +//! makes the guest operating system see it as a new one, so the assignment has
> +//! to stay stable for the lifetime of a VM. This crate therefore describes the
> +//! assignment as a static [`layout`] and only looks addresses up in it.
> +//!
> +//! The entry points below reproduce what the Perl implementation in
> +//! `PVE::QemuServer::PCI` does, so that both can be used interchangeably.
> +
> +pub mod constants;
> +
> +mod hardware_config;
> +pub use hardware_config::HardwareConfig;
> +
> +mod types;
> +pub use types::*;
> +
> +mod error;
> +pub use error::LayoutError;
> +
> +pub mod layout;
> +
> +/// Returns the number of the PCI bridge `id` sits on, or `None` if it is on
> +/// the root bus or not a known device.
> +pub fn pci_bridge_for_device(id: &str) -> Option<Bus> {
> +    layout::legacy::pci_bridge_for_device(id)
> +}
> +
> +/// Returns the address of the device with the configuration id `id`.
> +pub fn address(hw: &HardwareConfig, id: &str) -> Result<PciAddress, LayoutError> {
> +    layout::legacy::address(hw, id)
> +}
> diff --git a/pve-qemu-server-pci/src/types/bus.rs b/pve-qemu-server-pci/src/types/bus.rs
> new file mode 100644
> index 0000000..052ef00
> --- /dev/null
> +++ b/pve-qemu-server-pci/src/types/bus.rs
> @@ -0,0 +1,198 @@
> +#[cfg(test)]
> +use strum::VariantArray;
> +
> +use crate::{Device, LayoutError};
> +
> +/// The kinds of bridge PVE adds itself, as opposed to the ones a QEMU machine
> +/// type already brings along.
> +///
> +/// Each kind gets its own set of bridges so that the slot of a device only
> +/// depends on its own index, not on how many devices of other kinds exist.
> +#[cfg_attr(test, derive(VariantArray))]
> +#[derive(Hash, Clone, Copy, Debug, PartialEq, Eq)]
> +pub enum PveBridge {
> +    Net,
> +    Scsi,
> +    VirtioBlk,
> +    VirtioFs,
> +    Hostpci,
> +}
> +
> +impl PveBridge {
> +    /// The bus formed by the `index`th bridge of this kind.
> +    pub const fn new_bus(self, index: u8) -> Bus {
> +        Bus::Pve { kind: self, index }
> +    }
> +
> +    /// The device this kind of bridge carries at the given device index.
> +    pub const fn device(self, index: u8) -> Device {
> +        match self {
> +            PveBridge::Net => Device::Net(index),
> +            PveBridge::Scsi => Device::ScsiController(index),
> +            PveBridge::VirtioBlk => Device::VirtioBlk(index),
> +            PveBridge::VirtioFs => Device::VirtioFs(index),
> +            PveBridge::Hostpci => Device::Hostpci(index),
> +        }
> +    }
> +}
> +
> +impl std::fmt::Display for PveBridge {
> +    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
> +        let text = match self {
> +            PveBridge::Net => "net",
> +            PveBridge::Scsi => "scsi",
> +            PveBridge::VirtioBlk => "virtio-blk",
> +            PveBridge::VirtioFs => "virtio-fs",
> +            PveBridge::Hostpci => "hostpci",
> +        };
> +        f.write_str(text)
> +    }
> +}
> +
> +impl std::str::FromStr for PveBridge {
> +    type Err = LayoutError;
> +
> +    fn from_str(s: &str) -> Result<Self, Self::Err> {
> +        let kind = match s {
> +            "net" => PveBridge::Net,
> +            "scsi" => PveBridge::Scsi,
> +            "virtio-blk" => PveBridge::VirtioBlk,
> +            "virtio-fs" => PveBridge::VirtioFs,
> +            "hostpci" => PveBridge::Hostpci,
> +            _ => return Err(LayoutError::InvalidConfigId),
> +        };
> +        Ok(kind)
> +    }
> +}
> +
> +/// A bus a device can be attached to, identified the same way QEMU's `bus=`
> +/// parameter does.
> +#[derive(Hash, Clone, Copy, Debug, PartialEq, Eq)]
> +pub enum Bus {
> +    /// A PCI bus. Index 0 is the root bus of a non-PCIe machine.
> +    Pci(u8),
> +    /// A PCIe bus. Index 0 is the root bus of a PCIe machine.
> +    Pcie(u8),
> +    /// The bus behind a PCIe root port, which can hold a single device.
> +    RootPort(u8),
> +    /// A legacy, deprecated variant of a root port.
> +    #[deprecated]
> +    LegacyRootPort(u8),
> +    /// The PCI bus behind the q35 DMI-to-PCI bridge.
> +    Pcidmi,
> +    /// A bridge for the guest's built-in devices.
> +    Sys(u8),
> +    /// One of the bridges PVE adds per device kind, see [`PveBridge`].
> +    Pve { kind: PveBridge, index: u8 },
> +}
> +
> +type BusConstructor = fn(u8) -> Bus;
> +
> +impl Bus {
> +    /// The QEMU device model needed to create this bus below `parent`, or
> +    /// `None` if the bus is not created by a bridge device.
> +    pub fn bridge_model(self, parent: Bus) -> Option<&'static str> {
> +        match self {
> +            #[expect(deprecated)]
> +            Bus::Pcie(_) | Bus::RootPort(_) | Bus::LegacyRootPort(_) => None,
> +            Bus::Pcidmi => Some("i82801b11-bridge"),
> +            Bus::Sys(_) | Bus::Pci(_) | Bus::Pve { .. } => Some(match parent {
> +                #[expect(deprecated)]
> +                Bus::Pcie(_) | Bus::RootPort(_) | Bus::LegacyRootPort(_) => "pcie-pci-bridge",
> +                _ => "pci-bridge",
> +            }),
> +        }
> +    }
> +}
> +
> +impl std::fmt::Display for Bus {
> +    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
> +        match self {
> +            Bus::Pci(i) => write!(f, "pci.{i}"),
> +            Bus::Pcie(i) => write!(f, "pcie.{i}"),
> +            Bus::RootPort(i) => write!(f, "pve.root-port{i}"),
> +            #[expect(deprecated)]
> +            Bus::LegacyRootPort(i) => write!(f, "ich9-pcie-port-{i}"),
> +            Bus::Sys(i) => write!(f, "pve.sys{i}"),
> +            Bus::Pcidmi => write!(f, "pcidmi"),
> +            Bus::Pve { kind, index } => {
> +                write!(f, "pve.{kind}{index}")
> +            }
> +        }
> +    }
> +}
> +
> +impl std::str::FromStr for Bus {
> +    type Err = LayoutError;
> +
> +    fn from_str(s: &str) -> Result<Self, Self::Err> {
> +        // NOTE: the prefixes here must be checked before the generic 'pve.'
> +        // one below, which would otherwise swallow them.
> +        const PREFIXES: &[(&str, BusConstructor)] = &[
> +            ("pci.", Bus::Pci),
> +            ("pcie.", Bus::Pcie),
> +            ("pve.root-port", Bus::RootPort),
> +            #[expect(deprecated)]
> +            ("ich9-pcie-port-", Bus::LegacyRootPort),
> +            ("pve.sys", Bus::Sys),
> +        ];
> +
> +        if s == "pcidmi" {
> +            return Ok(Bus::Pcidmi);
> +        }
> +
> +        for (prefix, c) in PREFIXES {
> +            if let Some(rest) = s.strip_prefix(prefix) {
> +                let index: u8 = rest.parse().map_err(|_| LayoutError::InvalidConfigId)?;
> +                return Ok(c(index));
> +            }
> +        }
> +
> +        if let Some(rest) = s.strip_prefix("pve.")
> +            && let Some(idx) = rest.find(|c: char| c.is_ascii_digit())
> +        {
> +            let kind: PveBridge = rest[0..idx]
> +                .parse()
> +                .map_err(|_| LayoutError::InvalidConfigId)?;
> +            let index: u8 = rest[idx..]
> +                .parse()
> +                .map_err(|_| LayoutError::InvalidConfigId)?;
> +            return Ok(Bus::Pve { kind, index });
> +        }
> +
> +        Err(LayoutError::InvalidConfigId)
> +    }
> +}
> +
> +#[cfg(test)]
> +mod test {
> +    use strum::VariantArray;
> +
> +    use crate::Bus;
> +
> +    use super::PveBridge;
> +
> +    #[test]
> +    fn test_roundtrip() {
> +        let test = |bus: Bus| {
> +            let id = bus.to_string();
> +            let new_bus: Bus = id
> +                .parse()
> +                .unwrap_or_else(|_| panic!("could not parse {id}"));
> +            assert_eq!(bus, new_bus);
> +        };
> +
> +        test(Bus::Pcidmi);
> +        test(Bus::Pci(99));
> +        test(Bus::Pcie(99));
> +
> +        for kind in PveBridge::VARIANTS.iter().copied() {
> +            test(Bus::Pve { kind, index: 99 });
> +        }
> +
> +        test(Bus::RootPort(99));
> +        #[expect(deprecated)]
> +        test(Bus::LegacyRootPort(99));
> +        test(Bus::Sys(99));
> +    }
> +}
> diff --git a/pve-qemu-server-pci/src/types/device.rs b/pve-qemu-server-pci/src/types/device.rs
> new file mode 100644
> index 0000000..d3bf60b
> --- /dev/null
> +++ b/pve-qemu-server-pci/src/types/device.rs
> @@ -0,0 +1,173 @@
> +use std::{fmt::Display, str::FromStr};
> +use strum::{EnumDiscriminants, VariantArray};
> +
> +use crate::{Bus, LayoutError};
> +
> +/// A device that needs a PCI address.
> +///
> +/// Variants carrying an index map to a configuration key of the same name plus
> +/// that index, for example `Net(3)` is the guest's `net3`.
> +#[derive(Hash, Debug, Clone, Copy, PartialEq, Eq, EnumDiscriminants)]
> +#[strum_discriminants(derive(VariantArray))]
> +pub enum Device {
> +    Ahci,
> +    Balloon,
> +    Watchdog,
> +    GuestAgent,
> +    SpiceSerial,
> +    Rng,
> +    Audio,
> +    Ivshmem,
> +    Bridge(Bus),
> +    RootPort(Bus),
> +    Vga(u8),
> +    Viommu,
> +    VirtioFs(u8),
> +    Net(u8),
> +    ScsiController(u8),
> +    VirtioBlk(u8),
> +    Piix3Controller,
> +    XhciController(u8),
> +    Hostpci(u8),
> +    Hostpcie(u8),
> +    /// The EHCI USB controllers of the ICH9 chipset, numbered from one like
> +    /// their QEMU models.
> +    Ich9Ehci(u8),
> +    /// The UHCI USB controllers of the ICH9 chipset, numbered from one like
> +    /// their QEMU models.
> +    Ich9Uhci(u8),
> +    /// The HDA controller legacy q35 guests always have, independent of a
> +    /// configured `audio0`.
> +    Ich9Audio,
> +}
> +
> +impl Display for Device {
> +    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
> +        let text = match self {
> +            Device::Ahci => "ahci",
> +            Device::Balloon => "balloon",
> +            Device::Watchdog => "watchdog",
> +            Device::GuestAgent => "guest-agent",
> +            Device::SpiceSerial => "spice-serial",
> +            Device::Rng => "rng",
> +            Device::Audio => "audio",
> +            Device::Ivshmem => "ivshmem",
> +            Device::Viommu => "viommu",
> +            Device::Piix3Controller => "Piix3Controller",
> +            Device::Ich9Audio => "ich9-audio",
> +            _ => "",
> +        };
> +        if !text.is_empty() {
> +            return f.write_str(text);
> +        }
> +        let text = match self {
> +            Device::Bridge(bus) => bus.to_string(),
> +            Device::RootPort(bus) => bus.to_string(),
> +            Device::Vga(i) => format!("vga{i}"),
> +            Device::VirtioFs(i) => format!("virtiofs{i}"),
> +            Device::Net(i) => format!("net{i}"),
> +            Device::ScsiController(i) => format!("scsihw{i}"),
> +            Device::VirtioBlk(i) => format!("virtioblk{i}"),
> +            Device::XhciController(i) => format!("xhci{i}"),
> +            Device::Hostpci(i) => format!("hostpci{i}"),
> +            Device::Hostpcie(i) => format!("hostpcie{i}"),
> +            Device::Ich9Ehci(i) => format!("ich9-ehci{i}"),
> +            Device::Ich9Uhci(i) => format!("ich9-uhci{i}"),
> +            _ => String::new(),
> +        };
> +        write!(f, "{text}")
> +    }
> +}
> +
> +/// Rebuilds the single possible [`Device`] of a discriminant, which only works
> +/// for the variants that carry no index.
> +impl TryFrom<DeviceDiscriminants> for Device {
> +    type Error = LayoutError;
> +
> +    fn try_from(value: DeviceDiscriminants) -> Result<Self, Self::Error> {
> +        let res = match value {
> +            DeviceDiscriminants::Ahci => Device::Ahci,
> +            DeviceDiscriminants::Balloon => Device::Balloon,
> +            DeviceDiscriminants::Watchdog => Device::Watchdog,
> +            DeviceDiscriminants::GuestAgent => Device::GuestAgent,
> +            DeviceDiscriminants::SpiceSerial => Device::SpiceSerial,
> +            DeviceDiscriminants::Rng => Device::Rng,
> +            DeviceDiscriminants::Audio => Device::Audio,
> +            DeviceDiscriminants::Ivshmem => Device::Ivshmem,
> +            DeviceDiscriminants::Piix3Controller => Device::Piix3Controller,
> +            DeviceDiscriminants::Ich9Audio => Device::Ich9Audio,
> +            DeviceDiscriminants::Bridge
> +            | DeviceDiscriminants::RootPort
> +            | DeviceDiscriminants::Vga
> +            | DeviceDiscriminants::Viommu
> +            | DeviceDiscriminants::VirtioFs
> +            | DeviceDiscriminants::Net
> +            | DeviceDiscriminants::ScsiController
> +            | DeviceDiscriminants::VirtioBlk
> +            | DeviceDiscriminants::XhciController
> +            | DeviceDiscriminants::Hostpci
> +            | DeviceDiscriminants::Hostpcie
> +            | DeviceDiscriminants::Ich9Ehci
> +            | DeviceDiscriminants::Ich9Uhci => return Err(LayoutError::InvalidConfigId),
> +        };
> +        Ok(res)
> +    }
> +}
> +
> +type DeviceConstructor = fn(u8) -> Device;
> +
> +/// Parses the configuration id of a device, for example `net0` or `hostpci3`.
> +impl FromStr for Device {
> +    type Err = LayoutError;
> +
> +    fn from_str(s: &str) -> Result<Self, Self::Err> {
> +        // NOTE: order matters, the longer prefixes have to come first so that
> +        // 'virtio' does not shadow 'virtioscsi' and 'hostpci' not 'hostpcie'.
> +        const PREFIXES: &[(&str, DeviceConstructor)] = &[
> +            ("net", Device::Net),
> +            ("scsihw", Device::ScsiController),
> +            ("virtioscsi", Device::ScsiController),
> +            ("virtiofs", Device::VirtioFs),
> +            ("virtio", Device::VirtioBlk),
> +            ("hostpcie", Device::Hostpcie),
> +            ("hostpci", Device::Hostpci),
> +        ];
> +
> +        for (prefix, c) in PREFIXES {
> +            if let Some(index) = s.strip_prefix(prefix) {
> +                let index = index.parse().map_err(|_| LayoutError::InvalidConfigId)?;
> +                return Ok(c(index));
> +            }
> +        }
> +
> +        let class = match s {
> +            "viommu" => Device::Viommu,
> +            "vga" => Device::Vga(0),
> +            "vga1" => Device::Vga(1),
> +            "vga2" => Device::Vga(2),
> +            "vga3" => Device::Vga(3),
> +            "xhci" => Device::XhciController(0),
> +            "ahci0" => Device::Ahci,
> +            "balloon0" => Device::Balloon,
> +            "watchdog" => Device::Watchdog,
> +            "qga0" => Device::GuestAgent,
> +            "spice" => Device::SpiceSerial,
> +            "rng0" => Device::Rng,
> +            "audio0" => Device::Audio,
> +            "ivshmem" => Device::Ivshmem,
> +            // legacy rules
> +            "ehci" | "piix3" => Device::Piix3Controller,
> +            "legacy-igd" => Device::Vga(0),
> +            "pci.2-igd" => Device::Bridge(Bus::Pci(99)),
> +            other => {
> +                if let Ok(bridge) = other.parse() {
> +                    return Ok(Device::Bridge(bridge));
> +                }
> +
> +                return Err(LayoutError::InvalidConfigId);
> +            }
> +        };
> +
> +        Ok(class)
> +    }
> +}
> diff --git a/pve-qemu-server-pci/src/types/layout.rs b/pve-qemu-server-pci/src/types/layout.rs
> new file mode 100644
> index 0000000..889b961
> --- /dev/null
> +++ b/pve-qemu-server-pci/src/types/layout.rs
> @@ -0,0 +1,329 @@
> +use std::collections::VecDeque;
> +
> +use crate::constants::{FUNCTIONS_PER_DEVICE, SLOTS_PER_BUS, USABLE_SLOTS_PER_BUS};
> +use crate::{Bus, Device, LayoutError, PciAddress, PveBridge};
> +
> +/// What sits at one function number of a slot.
> +#[derive(Clone, Copy, Debug, PartialEq, Eq)]
> +pub enum Function<'a> {
> +    /// Occupied by something outside of this crate's control, for example by
> +    /// the machine type's own configuration file.
> +    Reserved,
> +    /// Free for future use. Kept explicit so that the surrounding entries keep
> +    /// their address when something is added later.
> +    Unused,
> +    Device(Device),
> +    /// A bridge with a statically known set of slots behind it.
> +    FixedBridge(Bus, &'a [Slot<'a>; SLOTS_PER_BUS]),
> +    /// A PCIe root port with the single device attached to it.
> +    RootPort(Bus, Device),
> +}
> +
> +/// What sits at one slot of a bus.
> +#[derive(Clone, Copy, Debug, PartialEq, Eq)]
> +pub enum Slot<'a> {
> +    /// Occupied by something outside of this crate's control.
> +    Reserved,
> +    /// Free for future use, see [`Function::Unused`].
> +    Unused,
> +    /// A single device on function 0.
> +    Single(Function<'a>),
> +    /// A multifunction slot with one entry per function number.
> +    Multi(&'a [Function<'a>; FUNCTIONS_PER_DEVICE]),
> +    /// As many bridges of `kind` as are needed to hold `max` devices. The
> +    /// bridges share this slot by using one function number each.
> +    DynamicBridges { kind: PveBridge, max: u8 },
> +}
> +
> +/// The complete slot assignment of a guest, starting at the root bus.
> +///
> +/// A layout is a static description: it lists every address the crate can
> +/// hand out, regardless of which devices a concrete guest configures.
> +#[derive(Clone, Copy, Debug, PartialEq)]
> +pub struct Layout<'a> {
> +    /// Whether the root bus is PCIe. Only the root bus is affected, nested
> +    /// buses stay plain PCI.
> +    pub pcie: bool,
> +    pub root: [Slot<'a>; SLOTS_PER_BUS],
> +}
> +
> +impl Layout<'_> {
> +    /// Looks up the address of a device in this layout.
> +    ///
> +    /// The first match wins, so the order of the layout is significant: a
> +    /// device may legitimately appear in two places, for example a SCSI
> +    /// controller that moves to its own bridge with `virtio-scsi-single`.
> +    pub fn find_device(&self, device: &Device) -> Result<PciAddress, LayoutError> {
> +        self.iter()
> +            .find_map(|used| (used.device == *device).then_some(used.addr))
> +            .ok_or(LayoutError::NoAddressFound(*device))
> +    }
> +
> +    /// Iterates over every address this layout hands out.
> +    ///
> +    /// Bridges are yielded before the devices behind them, so the result can
> +    /// be turned into a command line in order.
> +    pub fn iter(&self) -> AddressAssignments<'_> {
> +        AddressAssignments::new(self)
> +    }
> +}
> +
> +/// The address a layout assigns to a device.
> +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
> +pub struct AddressAssignment {
> +    pub addr: PciAddress,
> +    pub device: Device,
> +    /// Whether QEMU has to be told that other functions of this slot are in
> +    /// use. Only ever set on function 0.
> +    pub multifunction: bool,
> +}
> +
> +/// Iterator over the address assignments of a [`Layout`], see
> +/// [`Layout::iter`].
> +#[derive(Debug)]
> +pub struct AddressAssignments<'a> {
> +    resolved: VecDeque<AddressAssignment>,
> +    bridge_slot_stack: Vec<(Slot<'a>, PciAddress)>,
> +}
> +
> +impl<'a> AddressAssignments<'a> {
> +    pub(crate) fn new(layout: &Layout<'a>) -> Self {
> +        let bus_type = if layout.pcie {
> +            Bus::Pcie(0)
> +        } else {
> +            Bus::Pci(0)
> +        };
> +        let mut stack: Vec<(Slot<'a>, PciAddress)> = Vec::new();
> +        for (device, slot) in layout.root.iter().enumerate().rev() {
> +            let address = PciAddress::new(bus_type, device as u8, 0);
> +            stack.push((*slot, address));
> +        }
> +        Self {
> +            bridge_slot_stack: stack,
> +            resolved: VecDeque::new(),
> +        }
> +    }
> +
> +    /// Resolves one function of a slot into the queue of address assignments.
> +    ///
> +    /// A bridge is queued before the devices behind it: fixed bridges push
> +    /// their slots onto the stack, which is only drained once the queue is
> +    /// empty again.
> +    fn handle_function(&mut self, addr: PciAddress, function: Function<'a>, multifunction: bool) {
> +        match function {
> +            Function::Reserved | Function::Unused => {}
> +            Function::Device(device) => {
> +                self.resolved.push_back(AddressAssignment {
> +                    addr,
> +                    device,
> +                    multifunction,
> +                });
> +            }
> +            Function::RootPort(bus, device) => {
> +                self.resolved.push_back(AddressAssignment {
> +                    addr,
> +                    device: Device::RootPort(bus),
> +                    multifunction,
> +                });
> +                self.resolved.push_back(AddressAssignment {
> +                    addr: PciAddress::new(bus, 0, 0),
> +                    device,
> +                    multifunction: false,
> +                });
> +            }
> +            Function::FixedBridge(bus_type, slots) => {
> +                self.resolved.push_back(AddressAssignment {
> +                    addr,
> +                    device: Device::Bridge(bus_type),
> +                    multifunction,
> +                });
> +                for (device, slot) in slots.iter().enumerate().rev() {
> +                    let addr = PciAddress::new(bus_type, device as u8, 0);
> +                    self.bridge_slot_stack.push((*slot, addr));
> +                }
> +            }
> +        }
> +    }
> +
> +    /// Resolves a slot of dynamic bridges into the queue of address
> +    /// assignments.
> +    ///
> +    /// Unlike fixed bridges, the devices behind them are queued right away,
> +    /// as they are generated from the bridge kind rather than listed in the
> +    /// layout.
> +    fn handle_dynamic_bridges(&mut self, mut addr: PciAddress, kind: PveBridge, max: u8) {
> +        // the bridges share one slot, so there cannot be more of them than the
> +        // slot has function numbers
> +        let num_bridges = max.div_ceil(USABLE_SLOTS_PER_BUS);
> +        debug_assert!(usize::from(num_bridges) <= FUNCTIONS_PER_DEVICE);
> +
> +        for bridge_index in 0..num_bridges {
> +            addr.function = bridge_index;
> +            let bus = kind.new_bus(bridge_index);
> +            self.resolved.push_back(AddressAssignment {
> +                addr,
> +                device: Device::Bridge(bus),
> +                multifunction: bridge_index == 0 && num_bridges > 1,
> +            });
> +            for slot_nr in 1..=USABLE_SLOTS_PER_BUS {
> +                // u16 so that a bigger 'max' cannot overflow
> +                let index = u16::from(bridge_index) * u16::from(USABLE_SLOTS_PER_BUS)
> +                    + u16::from(slot_nr)
> +                    - 1;
> +                if index >= u16::from(max) {
> +                    break;
> +                }
> +                self.resolved.push_back(AddressAssignment {
> +                    addr: PciAddress::new(bus, slot_nr, 0),
> +                    device: kind.device(index as u8),
> +                    multifunction: false,
> +                });
> +            }
> +        }
> +    }
> +}
> +
> +impl Iterator for AddressAssignments<'_> {
> +    type Item = AddressAssignment;
> +
> +    fn next(&mut self) -> Option<Self::Item> {
> +        loop {
> +            if let Some(assignment) = self.resolved.pop_front() {
> +                return Some(assignment);
> +            }
> +            if let Some((slot, mut addr)) = self.bridge_slot_stack.pop() {
> +                match slot {
> +                    Slot::Reserved | Slot::Unused => {}
> +                    Slot::Single(function) => self.handle_function(addr, function, false),
> +                    Slot::Multi(functions) => {
> +                        for (function_nr, function) in functions.iter().enumerate() {
> +                            addr.function = function_nr as u8;
> +                            self.handle_function(addr, *function, function_nr == 0);
> +                        }
> +                    }
> +                    Slot::DynamicBridges { kind, max } => {
> +                        self.handle_dynamic_bridges(addr, kind, max)
> +                    }
> +                }
> +            }
> +
> +            if self.resolved.is_empty() && self.bridge_slot_stack.is_empty() {
> +                return None;
> +            }
> +        }
> +    }
> +}
> +
> +#[cfg(test)]
> +mod test {
> +    use crate::constants::SLOTS_PER_BUS;
> +    use crate::layout::bridge_slots;
> +    use crate::{AddressAssignment, Bus, Device, PciAddress};
> +
> +    use super::{Function, Layout, Slot};
> +
> +    const BRIDGE: [Slot; SLOTS_PER_BUS] =
> +        bridge_slots(&[Slot::Single(Function::Device(Device::Watchdog))]);
> +    const LAYOUT: Layout<'static> = Layout {
> +        pcie: false,
> +        root: crate::layout::bridge_slots(&[
> +            Slot::Single(Function::Device(Device::Rng)),
> +            Slot::Single(Function::Device(Device::Audio)),
> +            Slot::Reserved,
> +            Slot::Unused,
> +            Slot::Multi(&[
> +                Function::Reserved,
> +                Function::Unused,
> +                Function::Unused,
> +                Function::Unused,
> +                Function::Device(Device::Viommu),
> +                Function::Unused,
> +                Function::Unused,
> +                Function::Unused,
> +            ]),
> +            Slot::Single(Function::FixedBridge(Bus::Pci(99), &BRIDGE)),
> +        ]),
> +    };
> +
> +    #[test]
> +    fn basic_layout() {
> +        let test = |device: Device, addr: PciAddress| {
> +            let found = LAYOUT
> +                .find_device(&device)
> +                .unwrap_or_else(|_| panic!("could not find {device:?}"));
> +            assert_eq!(found, addr);
> +        };
> +
> +        test(Device::Rng, PciAddress::new(Bus::Pci(0), 1, 0));
> +        test(Device::Audio, PciAddress::new(Bus::Pci(0), 2, 0));
> +        test(Device::Viommu, PciAddress::new(Bus::Pci(0), 5, 4));
> +        test(Device::Watchdog, PciAddress::new(Bus::Pci(99), 1, 0));
> +    }
> +
> +    #[test]
> +    fn iterator() {
> +        let mut iter = LAYOUT.iter();
> +        let pci0 = Bus::Pci(0);
> +        assert_eq!(
> +            iter.next(),
> +            Some(AddressAssignment {
> +                addr: PciAddress {
> +                    bus: pci0,
> +                    device: 1,
> +                    function: 0
> +                },
> +                device: Device::Rng,
> +                multifunction: false,
> +            })
> +        );
> +        assert_eq!(
> +            iter.next(),
> +            Some(AddressAssignment {
> +                addr: PciAddress {
> +                    bus: pci0,
> +                    device: 2,
> +                    function: 0
> +                },
> +                device: Device::Audio,
> +                multifunction: false,
> +            })
> +        );
> +        assert_eq!(
> +            iter.next(),
> +            Some(AddressAssignment {
> +                addr: PciAddress {
> +                    bus: pci0,
> +                    device: 5,
> +                    function: 4
> +                },
> +                device: Device::Viommu,
> +                multifunction: false,
> +            })
> +        );
> +        assert_eq!(
> +            iter.next(),
> +            Some(AddressAssignment {
> +                addr: PciAddress {
> +                    bus: pci0,
> +                    device: 6,
> +                    function: 0
> +                },
> +                device: Device::Bridge(Bus::Pci(99)),
> +                multifunction: false,
> +            })
> +        );
> +        assert_eq!(
> +            iter.next(),
> +            Some(AddressAssignment {
> +                addr: PciAddress {
> +                    bus: Bus::Pci(99),
> +                    device: 1,
> +                    function: 0
> +                },
> +                device: Device::Watchdog,
> +                multifunction: false,
> +            })
> +        );
> +        assert_eq!(iter.next(), None);
> +    }
> +}
> diff --git a/pve-qemu-server-pci/src/types/mod.rs b/pve-qemu-server-pci/src/types/mod.rs
> new file mode 100644
> index 0000000..3847745
> --- /dev/null
> +++ b/pve-qemu-server-pci/src/types/mod.rs
> @@ -0,0 +1,11 @@
> +mod bus;
> +pub use bus::{Bus, PveBridge};
> +
> +mod device;
> +pub use device::Device;
> +
> +mod pci_address;
> +pub use pci_address::PciAddress;
> +
> +mod layout;
> +pub use layout::{AddressAssignment, AddressAssignments, Function, Layout, Slot};
> diff --git a/pve-qemu-server-pci/src/types/pci_address.rs b/pve-qemu-server-pci/src/types/pci_address.rs
> new file mode 100644
> index 0000000..dcef298
> --- /dev/null
> +++ b/pve-qemu-server-pci/src/types/pci_address.rs
> @@ -0,0 +1,36 @@
> +use crate::Bus;
> +
> +/// The complete address of a device: the bus it sits on plus the slot and
> +/// function number within that bus.
> +#[derive(Hash, Debug, Clone, Copy, PartialEq, Eq)]
> +pub struct PciAddress {
> +    pub bus: Bus,
> +    pub device: u8,
> +    pub function: u8,
> +}
> +
> +impl PciAddress {
> +    pub const fn new(bus: Bus, device: u8, function: u8) -> Self {
> +        Self {
> +            bus,
> +            device,
> +            function,
> +        }
> +    }
> +
> +    /// Renders the address as the `,bus=...,addr=...` suffix of a QEMU
> +    /// `-device` argument, including the leading comma.
> +    ///
> +    /// The function number is only emitted when it is not zero, matching what
> +    /// the Perl implementation produces.
> +    pub fn to_qemu_addr(&self) -> String {
> +        let bus = self.bus;
> +        let function = if self.function > 0 {
> +            format!(".{}", self.function)
> +        } else {
> +            String::new()
> +        };
> +
> +        format!(",bus={bus},addr=0x{:x}{function}", self.device)
> +    }
> +}
> diff --git a/rustfmt.toml b/rustfmt.toml
> new file mode 100644
> index 0000000..f3e454b
> --- /dev/null
> +++ b/rustfmt.toml
> @@ -0,0 +1,2 @@
> +edition = "2024"
> +style_edition = "2024"
> -- 
> 2.47.3
> 
> 
> 
> 
> 

-- 




^ permalink raw reply	[flat|nested] 12+ messages in thread

end of thread, other threads:[~2026-10-15 12:19 UTC | newest]

Thread overview: 12+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-10-15  8:10 [RFC proxmox-perl-rs/qemu-server/qemu-server-rs v2 0/9] pci-handling rewrite (part 1/2/3) Dominik Csapak
2026-10-15  8:10 ` [PATCH pve-qemu-server-rs v2 1/9] add pve-qemu-server-pci crate for guest PCI address generation Dominik Csapak
2026-10-15 12:16   ` Wolfgang Bumiller
2026-10-15  8:10 ` [PATCH pve-qemu-server-rs v2 2/9] pci: legacy: generate pci bridges Dominik Csapak
2026-10-15  8:10 ` [PATCH pve-qemu-server-rs v2 3/9] pci: legacy: generate q35 chipset devices and hostpcie root ports Dominik Csapak
2026-10-15  8:10 ` [PATCH pve-qemu-server-rs v2 4/9] pci: layout: add grouped PCI and PCIe layouts Dominik Csapak
2026-10-15  8:10 ` [PATCH proxmox-perl-rs v2 5/9] pve: add bindings for `pve-qemu-server-pci` crate Dominik Csapak
2026-10-15 11:47   ` Wolfgang Bumiller
2026-10-15  8:10 ` [PATCH qemu-server v2 6/9] pci: use PVE::RS::PCI bindings for addresses and bus devices Dominik Csapak
2026-10-15  8:10 ` [PATCH qemu-server v2 7/9] tree-wide: use HardwareConfig for the machine version checks Dominik Csapak
2026-10-15  8:10 ` [PATCH qemu-server v2 8/9] cfg2cmd: q35: generate chipset devices with machine version 11.1+pve1 Dominik Csapak
2026-10-15  8:10 ` [PATCH qemu-server v2 9/9] machine: add 'pci-layout' option Dominik Csapak

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal