public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [RFC proxmox-perl-rs/qemu-server/qemu-server-rs 0/9] pci-handling rewrite (part 1)
@ 2026-09-22 10:55 Dominik Csapak
  2026-09-22 10:55 ` [PATCH pve-qemu-server-rs 1/9] add pve-qemu-server-pci crate for guest PCI address generation Dominik Csapak
                   ` (9 more replies)
  0 siblings, 10 replies; 11+ messages in thread
From: Dominik Csapak @ 2026-09-22 10:55 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-toplogy (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 onto new addresses on there, using whatever free
address there exists.

This lead 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 `Machine` 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 (partially this series)

This would introduce a new layout ("v2") that can be used in an opt-in manner,
and is vastly more logically constructed, which makes it easy to extend and
argue about. See the "preview" patch (last of pve-qemu-server-rs) for how this
layout can look like.

This part would also entail restructuring the addr calls in qemu-server's perl
code, so to only have a single entry point ("address" for example) instead of
splitting between 'print_pci_addr' and 'print_pcie_addr'.

In this phase we could also emit the commandline for devices that are currently
in the q35 config files read with readconfig.

## Phase 3 - more perl to rust code port

To better work with the layout, we'd need to pull more code from qemu-server
into rust, for this plan mostly from PCI.pm and USB.pm but maybe some other
helpers too (version comparsion comes to mind).

Here we could start using the schema from pve-api-types to parse e.g. the
config rust-side, but the schema still has to live perl side (ofc).

## Phase 4 - Host and NUMA layouts

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
toplogy 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.
* There are still some rough edges (e.g. hardcoding the perl config max values
  in rust again), but it should work as intended and it passes our qemu-server
  regression tests.
* 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 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 to the layouting changes in perl, or keep some parts in
  perl, that's fine with me too and I'll change my efforts accordingly

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


pve-qemu-server-rs:

Dominik Csapak (4):
  add pve-qemu-server-pci crate for guest PCI address generation
  pci: add machine abstraction and PCI bridge generation
  pci: layout: add v2 PCI and PCIe layouts
  fixup! add pve-qemu-server-pci crate for guest PCI address generation


proxmox-perl-rs:

Dominik Csapak (2):
  pve: add bindings for `pve-qemu-server-pci` crate
  pve: pci bindings: add bindings for the `Machine` struct

 pve-rs/Cargo.toml                  |  1 +
 pve-rs/Makefile                    |  2 ++
 pve-rs/src/bindings/mod.rs         |  3 ++
 pve-rs/src/bindings/pci/machine.rs | 45 ++++++++++++++++++++++++++++
 pve-rs/src/bindings/pci/mod.rs     | 48 ++++++++++++++++++++++++++++++
 5 files changed, 99 insertions(+)
 create mode 100644 pve-rs/src/bindings/pci/machine.rs
 create mode 100644 pve-rs/src/bindings/pci/mod.rs


qemu-server:

Dominik Csapak (3):
  pci: use PVE::RS::PCI bindings
  helpers: factor out the version parts parsing
  pci: bridges: use the rust `Machine` struct to pass parameters

 src/PVE/QemuServer.pm                    |   4 +-
 src/PVE/QemuServer/Helpers.pm            |  22 +-
 src/PVE/QemuServer/PCI.pm                | 296 +++--------------------
 src/test/Makefile                        |   5 +-
 src/test/TestsCommon/CommandLineMocks.pm |   3 +
 src/test/run_pci_addr_checks.pl          | 141 -----------
 6 files changed, 56 insertions(+), 415 deletions(-)
 delete mode 100755 src/test/run_pci_addr_checks.pl


Summary over all repositories:
  11 files changed, 155 insertions(+), 415 deletions(-)

-- 
Generated by murpp 0.11.0




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

* [PATCH pve-qemu-server-rs 1/9] add pve-qemu-server-pci crate for guest PCI address generation
  2026-09-22 10:55 [RFC proxmox-perl-rs/qemu-server/qemu-server-rs 0/9] pci-handling rewrite (part 1) Dominik Csapak
@ 2026-09-22 10:55 ` Dominik Csapak
  2026-09-22 10:55 ` [PATCH pve-qemu-server-rs 2/9] pci: add machine abstraction and PCI bridge generation Dominik Csapak
                   ` (8 subsequent siblings)
  9 siblings, 0 replies; 11+ messages in thread
From: Dominik Csapak @ 2026-09-22 10:55 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, so both implementations can be used interchangeably.

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           |  38 ++
 pve-qemu-server-pci/debian/copyright         |  18 +
 pve-qemu-server-pci/debian/debcargo.toml     |   7 +
 pve-qemu-server-pci/src/constants.rs         |  15 +
 pve-qemu-server-pci/src/error.rs             |  23 +
 pve-qemu-server-pci/src/layout/legacy.rs     | 655 +++++++++++++++++++
 pve-qemu-server-pci/src/layout/mod.rs        |  48 ++
 pve-qemu-server-pci/src/lib.rs               |  44 ++
 pve-qemu-server-pci/src/types/bus.rs         | 188 ++++++
 pve-qemu-server-pci/src/types/device.rs      | 123 ++++
 pve-qemu-server-pci/src/types/layout.rs      | 345 ++++++++++
 pve-qemu-server-pci/src/types/mod.rs         |  11 +
 pve-qemu-server-pci/src/types/pci_address.rs |  36 +
 rustfmt.toml                                 |   2 +
 22 files changed, 1857 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/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..12444c1
--- /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..31744dc
--- /dev/null
+++ b/pve-qemu-server-pci/debian/control
@@ -0,0 +1,38 @@
+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-log-0.4+default-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-log-0.4+default-dev,
+ 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..a96c519
--- /dev/null
+++ b/pve-qemu-server-pci/src/constants.rs
@@ -0,0 +1,15 @@
+//! Upper bounds for the per-guest device counts the layouts reserve slots for.
+//!
+//! These mirror the limits enforced by the qemu-server configuration schema.
+
+pub const MAX_NET_DEVICES: u8 = 32;
+pub const MAX_SCSI_DEVICES: u8 = 31;
+pub const MAX_VIRTIO_BLK_DEVICES: u8 = 16;
+pub const MAX_VIRTIOFS_DEVICES: u8 = 10;
+pub const MAX_HOSTPCI_DEVICES: u8 = 16;
+
+/// Number of slots on a PCI bus. Slot 0x00 is always taken by the bridge
+/// itself, so only [`USABLE_BRIDGE_SLOTS`] of them can hold a device.
+pub(crate) const BRIDGE_SLOT_NUM: usize = 32;
+pub(crate) const USABLE_BRIDGE_SLOTS: u8 = BRIDGE_SLOT_NUM as u8 - 1;
+pub(crate) const FUNCTIONS_NUM: 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..6cd5abd
--- /dev/null
+++ b/pve-qemu-server-pci/src/error.rs
@@ -0,0 +1,23 @@
+use std::{error::Error, fmt::Display};
+
+/// Errors that can occur while looking up a PCI address.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[non_exhaustive]
+pub enum PciConfigError {
+    /// 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,
+}
+
+impl Display for PciConfigError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let text = match self {
+            PciConfigError::InvalidConfigId => "invalid configuration id",
+            PciConfigError::NoAddressFound => "no such address found",
+        };
+        f.write_str(text)
+    }
+}
+
+impl Error for PciConfigError {}
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..40b3265
--- /dev/null
+++ b/pve-qemu-server-pci/src/layout/legacy.rs
@@ -0,0 +1,655 @@
+use pve_api_types::ClusterResourceHostArch;
+
+use crate::constants::BRIDGE_SLOT_NUM;
+use crate::layout::bridge_slots;
+use crate::{Bus, Device, DeviceLayout, Function, PciConfigError, Slot};
+
+const PCI_1: [Slot; BRIDGE_SLOT_NUM] = 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; BRIDGE_SLOT_NUM] = 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; BRIDGE_SLOT_NUM] = 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; BRIDGE_SLOT_NUM] = 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) -> DeviceLayout<'a> {
+    DeviceLayout {
+        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.
+pub(crate) const LEGACY_PCIE_LAYOUT: DeviceLayout = DeviceLayout {
+    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(5, Device::Hostpci(4)),
+            Function::RootPort(6, Device::Hostpci(5)),
+            Function::RootPort(7, Device::Hostpci(6)),
+            Function::RootPort(8, Device::Hostpci(7)),
+            Function::RootPort(9, Device::Hostpci(8)),
+            Function::RootPort(10, Device::Hostpci(9)),
+            Function::RootPort(11, Device::Hostpci(10)),
+            Function::RootPort(12, Device::Hostpci(11)),
+        ]),
+        Slot::Multi(&[
+            Function::RootPort(13, Device::Hostpci(12)),
+            Function::RootPort(14, Device::Hostpci(13)),
+            Function::RootPort(15, Device::Hostpci(14)),
+            Function::RootPort(16, Device::Hostpci(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::Piix3Controller), // uhci-4
+            Function::Device(Device::Piix3Controller), // uhci-5
+            Function::Device(Device::Piix3Controller), // uhci-6
+            Function::Unused,
+            Function::Unused,
+            Function::Unused,
+            Function::Unused,
+            Function::Device(Device::Piix3Controller), // ehci-2
+        ]),
+        single_device!(Audio),
+        Slot::Multi(&[
+            Function::RootPort(1, Device::Hostpci(0)),
+            Function::RootPort(2, Device::Hostpci(1)),
+            Function::RootPort(3, Device::Hostpci(2)),
+            Function::RootPort(4, Device::Hostpci(3)),
+            Function::Unused,
+            Function::Unused,
+            Function::Unused,
+            Function::Unused,
+        ]),
+        Slot::Multi(&[
+            // TODO
+            Function::Device(Device::Piix3Controller), // uhci-1
+            Function::Device(Device::Piix3Controller), // uhci-2
+            Function::Device(Device::Piix3Controller), // uhci-3
+            Function::Unused,
+            Function::Unused,
+            Function::Unused,
+            Function::Unused,
+            Function::Device(Device::Piix3Controller), // ehci
+        ]),
+        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::Unused,
+    ],
+};
+
+/// 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 shared with
+/// [`LEGACY_PCIE_LAYOUT`].
+pub(crate) const LEGACY_PCIE_LAYOUT_WIN7: DeviceLayout = DeviceLayout {
+    pcie: true,
+    root: [
+        Slot::Reserved,
+        Slot::Reserved,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        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(0)),
+        single_device!(Hostpci(1)),
+        single_device!(Hostpci(2)),
+        single_device!(Hostpci(3)),
+        Slot::Reserved,
+        single_device!(Hostpci(11)),
+        single_device!(Hostpci(12)),
+        single_device!(Hostpci(13)),
+        single_device!(Hostpci(14)),
+        single_device!(Hostpci(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
+}
+
+/// Maps legacy PCIE IDs to ones we can represent here.
+/// Returns the new ID and if the value is for win7 layout.
+fn map_legacy_pcie_id(original: &str) -> (&str, bool) {
+    // map hostpciXbus0 to hostpciX
+    if let Some(s) = original.strip_suffix("bus0")
+        && s.starts_with("hostpci")
+    {
+        return (s, true);
+    }
+
+    (original, false)
+}
+
+/// Looks up the PCI address of a device, see [`crate::print_pci_addr`].
+pub(crate) fn print_pci_addr(
+    id: &str,
+    arch: ClusterResourceHostArch,
+) -> Result<String, PciConfigError> {
+    let new_id = map_legacy_pci_id(id);
+    // aarch64 has no PCI host bridge, only a PCIe one
+    let pcie = arch == ClusterResourceHostArch::Aarch64;
+    let layout = get_legacy_pci_layout(pcie, is_virtio_scsi_single_id(new_id));
+    let addr = layout.find_device(&new_id.parse()?)?;
+    Ok(addr.to_qemu_addr())
+}
+
+/// Looks up the PCIe address of a device, see [`crate::print_pcie_addr`].
+pub(crate) fn print_pcie_addr(id: &str) -> Result<String, PciConfigError> {
+    let (new_id, win7) = map_legacy_pcie_id(id);
+    let layout = if win7 {
+        LEGACY_PCIE_LAYOUT_WIN7
+    } else {
+        LEGACY_PCIE_LAYOUT
+    };
+    let addr = layout.find_device(&new_id.parse()?)?;
+    Ok(addr.to_qemu_addr())
+}
+
+/// Renders a PCIe root port device, see [`crate::print_pcie_root_port`].
+pub(crate) fn print_pcie_root_port(index: u8) -> Result<String, PciConfigError> {
+    // root ports are numbered from one in the layout, hostpci devices from zero
+    let index = index.checked_add(1).ok_or(PciConfigError::NoAddressFound)?;
+    let res = LEGACY_PCIE_LAYOUT
+        .print_root_port(index)?
+        .replace("pve.root-port", "ich9-pcie-port-");
+
+    Ok(res)
+}
+
+#[cfg(test)]
+pub(crate) mod test {
+    use pve_api_types::ClusterResourceHostArch;
+
+    use std::collections::HashSet;
+
+    use super::{
+        LEGACY_PCIE_LAYOUT, get_legacy_pci_layout, print_pci_addr, print_pcie_addr,
+        print_pcie_root_port,
+    };
+    use crate::{Bus, Device, DeviceLayout, PciAddress};
+
+    use crate::constants::MAX_HOSTPCI_DEVICES;
+
+    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 x86 = ClusterResourceHostArch::X8664;
+        let arm = ClusterResourceHostArch::Aarch64;
+        for (input, bus, device) in LEGACY_ADDRS {
+            let addr = print_pci_addr(input, x86).unwrap_or_else(|_| panic!("{input} not found"));
+            let expected = pci_addr(false, bus, device).to_qemu_addr();
+            assert_eq!(addr, expected);
+
+            // arm is on pcie
+            let addr = print_pci_addr(input, arm).unwrap_or_else(|_| panic!("{input} not found"));
+            let expected = pci_addr(bus == 0, bus, device).to_qemu_addr();
+            assert_eq!(addr, expected);
+        }
+    }
+
+    #[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 = [
+            ("hostpci0", true, 1),
+            ("hostpci1", true, 2),
+            ("hostpci2", true, 3),
+            ("hostpci3", true, 4),
+            ("hostpci4", true, 5),
+            ("hostpci5", true, 6),
+            ("hostpci6", true, 7),
+            ("hostpci7", true, 8),
+            ("hostpci8", true, 9),
+            ("hostpci9", true, 10),
+            ("hostpci10", true, 11),
+            ("hostpci11", true, 12),
+            ("hostpci12", true, 13),
+            ("hostpci13", true, 14),
+            ("hostpci14", true, 15),
+            ("hostpci15", true, 16),
+            ("hostpci0bus0", false, 16),
+            ("hostpci1bus0", false, 17),
+            ("hostpci2bus0", false, 18),
+            ("hostpci3bus0", false, 19),
+            ("hostpci4bus0", false, 9),
+            ("hostpci5bus0", false, 10),
+            ("hostpci6bus0", false, 11),
+            ("hostpci7bus0", false, 12),
+            ("hostpci8bus0", false, 13),
+            ("hostpci9bus0", false, 14),
+            ("hostpci10bus0", false, 15),
+            ("hostpci11bus0", false, 21),
+            ("hostpci12bus0", false, 22),
+            ("hostpci13bus0", false, 23),
+            ("hostpci14bus0", false, 24),
+            ("hostpci15bus0", false, 25),
+            ("ivshmem", false, 20),
+            ("vga", false, 1),
+        ];
+
+        for (input, rp, num) in tests {
+            let addr =
+                print_pcie_addr(input).unwrap_or_else(|_| panic!("device not found: {input}"));
+            let (bus, device) = if rp {
+                (Bus::RootPort(num), 0)
+            } else {
+                (Bus::Pcie(0), num)
+            };
+            let expected = PciAddress::new(bus, device, 0).to_qemu_addr();
+            assert_eq!(addr, expected);
+        }
+    }
+
+    #[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),
+        ];
+        for i in 0..MAX_HOSTPCI_DEVICES {
+            let (addr, fnr) = expected[i as usize];
+            let index = i + 1;
+            let expected = 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}",
+            );
+            let found = print_pcie_root_port(i).expect("could not be found");
+            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.
+    fn assert_buses_before_devices(layout: &DeviceLayout) {
+        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(i) => buses.insert(Bus::RootPort(i)),
+                _ => 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));
+    }
+}
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..c81d730
--- /dev/null
+++ b/pve-qemu-server-pci/src/layout/mod.rs
@@ -0,0 +1,48 @@
+use crate::Slot;
+use crate::constants::BRIDGE_SLOT_NUM;
+
+// 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>; BRIDGE_SLOT_NUM] {
+    const PLACEHOLDER: Slot = Slot::Unused;
+    let mut out = [PLACEHOLDER; BRIDGE_SLOT_NUM];
+    if slots.len() > BRIDGE_SLOT_NUM - 1 {
+        panic!("too many devices on bridge");
+    }
+    out[0] = Slot::Reserved;
+    let mut i = 1;
+    while i < BRIDGE_SLOT_NUM && i - 1 < slots.len() {
+        out[i] = slots[i - 1];
+        i += 1;
+    }
+    out
+}
+
+#[cfg(test)]
+mod test {
+    use crate::Slot;
+    use crate::constants::BRIDGE_SLOT_NUM;
+    use crate::layout::bridge_slots;
+
+    #[test]
+    fn test_bridge_slots() {
+        const BRIDGE: [Slot; BRIDGE_SLOT_NUM] =
+            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..5e8b8a7
--- /dev/null
+++ b/pve-qemu-server-pci/src/lib.rs
@@ -0,0 +1,44 @@
+//! 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 types;
+use pve_api_types::ClusterResourceHostArch as Arch;
+pub use types::*;
+
+mod error;
+pub use error::PciConfigError;
+
+pub mod layout;
+
+/// Returns the `,bus=...,addr=...` suffix for the PCI address of `id`.
+///
+/// An `arch` that cannot be parsed is treated as x86_64.
+pub fn print_pci_addr(id: &str, arch: &str) -> Result<String, PciConfigError> {
+    layout::legacy::print_pci_addr(id, arch.parse().unwrap_or(Arch::X8664))
+}
+
+/// Returns the `,bus=...,addr=...` suffix for the PCIe address of `id`.
+///
+/// Passing `hostpci<N>bus0` instead of `hostpci<N>` asks for the address on
+/// the Windows 7 layout, which puts the device on the root bus rather than
+/// behind a root port.
+pub fn print_pcie_addr(id: &str) -> Result<String, PciConfigError> {
+    layout::legacy::print_pcie_addr(id)
+}
+
+/// Returns the QEMU `-device` arguments for the root port of `hostpci<index>`.
+///
+/// Note that the first four root ports are already part of the `pve-q35*.cfg`
+/// machine definitions, so callers must not add those a second time.
+pub fn print_pcie_root_port(index: u8) -> Result<String, PciConfigError> {
+    layout::legacy::print_pcie_root_port(index)
+}
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..37ad57d
--- /dev/null
+++ b/pve-qemu-server-pci/src/types/bus.rs
@@ -0,0 +1,188 @@
+#[cfg(test)]
+use strum::VariantArray;
+
+use crate::{Device, PciConfigError};
+
+/// 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 = PciConfigError;
+
+    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(PciConfigError::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),
+    /// 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 {
+    /// Renders the bus as the id QEMU refers to it by.
+    ///
+    /// This is the inverse of the [`std::str::FromStr`] implementation.
+    pub fn to_id(self) -> String {
+        match self {
+            Bus::Pci(i) => format!("pci.{i}"),
+            Bus::Pcie(i) => format!("pcie.{i}"),
+            Bus::RootPort(i) => format!("pve.root-port{i}"),
+            Bus::Sys(i) => format!("pve.sys{i}"),
+            Bus::Pcidmi => "pcidmi".to_string(),
+            Bus::Pve { kind, index } => {
+                format!("pve.{kind}{index}")
+            }
+        }
+    }
+
+    /// 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 {
+            Bus::Pcie(_) | Bus::RootPort(_) => None,
+            Bus::Pcidmi => Some("i82801b11-bridge"),
+            Bus::Sys(_) | Bus::Pci(_) | Bus::Pve { .. } => Some(match parent {
+                Bus::Pcie(_) | Bus::RootPort(_) => "pcie-pci-bridge",
+                _ => "pci-bridge",
+            }),
+        }
+    }
+}
+
+impl std::str::FromStr for Bus {
+    type Err = PciConfigError;
+
+    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),
+            ("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(|_| PciConfigError::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(|_| PciConfigError::InvalidConfigId)?;
+            let index: u8 = rest[idx..]
+                .parse()
+                .map_err(|_| PciConfigError::InvalidConfigId)?;
+            return Ok(Bus::Pve { kind, index });
+        }
+
+        Err(PciConfigError::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_id();
+            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));
+        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..f63bf39
--- /dev/null
+++ b/pve-qemu-server-pci/src/types/device.rs
@@ -0,0 +1,123 @@
+use std::str::FromStr;
+use strum::{EnumDiscriminants, VariantArray};
+
+use crate::{Bus, PciConfigError};
+
+/// 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(u8),
+    Vga(u8),
+    Viommu,
+    VirtioFs(u8),
+    Net(u8),
+    ScsiController(u8),
+    VirtioBlk(u8),
+    Piix3Controller,
+    XhciController(u8),
+    Hostpci(u8),
+    Hostpcie(u8),
+}
+
+/// 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 = PciConfigError;
+
+    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::Bridge
+            | DeviceDiscriminants::RootPort
+            | DeviceDiscriminants::Vga
+            | DeviceDiscriminants::Viommu
+            | DeviceDiscriminants::VirtioFs
+            | DeviceDiscriminants::Net
+            | DeviceDiscriminants::ScsiController
+            | DeviceDiscriminants::VirtioBlk
+            | DeviceDiscriminants::XhciController
+            | DeviceDiscriminants::Hostpci
+            | DeviceDiscriminants::Hostpcie => return Err(PciConfigError::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 = PciConfigError;
+
+    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(|_| PciConfigError::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(PciConfigError::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..18977af
--- /dev/null
+++ b/pve-qemu-server-pci/src/types/layout.rs
@@ -0,0 +1,345 @@
+use std::collections::VecDeque;
+
+use crate::constants::{BRIDGE_SLOT_NUM, FUNCTIONS_NUM, USABLE_BRIDGE_SLOTS};
+use crate::{Bus, Device, PciAddress, PciConfigError, 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>; BRIDGE_SLOT_NUM]),
+    /// A PCIe root port with the single device attached to it.
+    RootPort(u8, 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 holding up to [`FUNCTIONS_NUM`] entries.
+    Multi(&'a [Function<'a>; FUNCTIONS_NUM]),
+    /// As many bridges of `kind` as are needed to hold `max` devices. The
+    /// bridges share this slot by using one function number each.
+    DynamicBridge { 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 DeviceLayout<'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>; BRIDGE_SLOT_NUM],
+}
+
+impl DeviceLayout<'_> {
+    /// Renders the QEMU `-device` arguments for the `index`th PCIe root port
+    /// of this layout.
+    pub fn print_root_port(&self, index: u8) -> Result<String, PciConfigError> {
+        let pci_addr = self
+            .find_root_port(index)
+            .ok_or(PciConfigError::NoAddressFound)?;
+
+        let id = Bus::RootPort(index).to_id();
+
+        let bus = format!("bus={}", pci_addr.bus.to_id());
+        let addr = format!("addr={:02x}.{}", pci_addr.device, pci_addr.function);
+
+        Ok(format!(
+            "pcie-root-port,id={id},{addr},x-speed=16,x-width=32,multifunction=on,{bus},port={index},chassis={index}"
+        ))
+    }
+
+    /// 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, PciConfigError> {
+        self.iter()
+            .find_map(|used| (used.device == *device).then_some(used.addr))
+            .ok_or(PciConfigError::NoAddressFound)
+    }
+
+    fn find_root_port(&self, i: u8) -> Option<PciAddress> {
+        self.iter()
+            .find_map(|used| (used.device == Device::RootPort(i)).then_some(used.addr))
+    }
+
+    /// 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) -> UsedSlotIterator<'_> {
+        UsedSlotIterator::new(self)
+    }
+}
+
+/// A slot of a layout that holds a device, together with its address.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub struct UsedSlot {
+    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 used slots of a [`DeviceLayout`], see
+/// [`DeviceLayout::iter`].
+#[derive(Debug)]
+pub struct UsedSlotIterator<'a> {
+    resolved_slots: VecDeque<UsedSlot>,
+    bridge_slot_stack: Vec<(Slot<'a>, PciAddress)>,
+}
+
+impl<'a> UsedSlotIterator<'a> {
+    pub(crate) fn new(layout: &DeviceLayout<'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_slots: VecDeque::new(),
+        }
+    }
+
+    /// Resolves one function of a slot into the queue of used slots.
+    ///
+    /// 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_slots.push_back(UsedSlot {
+                    addr,
+                    device,
+                    multifunction,
+                });
+            }
+            Function::RootPort(i, device) => {
+                self.resolved_slots.push_back(UsedSlot {
+                    addr,
+                    device: Device::RootPort(i),
+                    multifunction,
+                });
+                self.resolved_slots.push_back(UsedSlot {
+                    addr: PciAddress::new(Bus::RootPort(i), 0, 0),
+                    device,
+                    multifunction: false,
+                });
+            }
+            Function::FixedBridge(bus_type, slots) => {
+                self.resolved_slots.push_back(UsedSlot {
+                    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));
+                }
+            }
+        }
+    }
+}
+
+impl Iterator for UsedSlotIterator<'_> {
+    type Item = UsedSlot;
+
+    fn next(&mut self) -> Option<Self::Item> {
+        loop {
+            if let Some(slot) = self.resolved_slots.pop_front() {
+                return Some(slot);
+            }
+            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::DynamicBridge { kind, max } => {
+                        // 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_BRIDGE_SLOTS);
+                        debug_assert!(usize::from(num_bridges) <= FUNCTIONS_NUM);
+
+                        for bridge_index in 0..num_bridges {
+                            addr.function = bridge_index;
+                            let bus = Bus::Pve {
+                                kind,
+                                index: bridge_index,
+                            };
+                            self.resolved_slots.push_back(UsedSlot {
+                                addr,
+                                device: Device::Bridge(bus),
+                                multifunction: bridge_index == 0 && num_bridges > 1,
+                            });
+                            for slot_nr in 1..=USABLE_BRIDGE_SLOTS {
+                                // u16 so that a bigger 'max' cannot overflow
+                                let index = u16::from(bridge_index)
+                                    * u16::from(USABLE_BRIDGE_SLOTS)
+                                    + u16::from(slot_nr)
+                                    - 1;
+                                if index >= u16::from(max) {
+                                    break;
+                                }
+                                self.resolved_slots.push_back(UsedSlot {
+                                    addr: PciAddress::new(bus, slot_nr, 0),
+                                    device: kind.device(index as u8),
+                                    multifunction: false,
+                                });
+                            }
+                        }
+                    }
+                }
+            }
+
+            if self.resolved_slots.is_empty() && self.bridge_slot_stack.is_empty() {
+                return None;
+            }
+        }
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use crate::constants::BRIDGE_SLOT_NUM;
+    use crate::layout::bridge_slots;
+    use crate::{Bus, Device, PciAddress, UsedSlot};
+
+    use super::{DeviceLayout, Function, Slot};
+
+    const BRIDGE: [Slot; BRIDGE_SLOT_NUM] =
+        bridge_slots(&[Slot::Single(Function::Device(Device::Watchdog))]);
+    const LAYOUT: DeviceLayout<'static> = DeviceLayout {
+        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(UsedSlot {
+                addr: PciAddress {
+                    bus: pci0,
+                    device: 1,
+                    function: 0
+                },
+                device: Device::Rng,
+                multifunction: false,
+            })
+        );
+        assert_eq!(
+            iter.next(),
+            Some(UsedSlot {
+                addr: PciAddress {
+                    bus: pci0,
+                    device: 2,
+                    function: 0
+                },
+                device: Device::Audio,
+                multifunction: false,
+            })
+        );
+        assert_eq!(
+            iter.next(),
+            Some(UsedSlot {
+                addr: PciAddress {
+                    bus: pci0,
+                    device: 5,
+                    function: 4
+                },
+                device: Device::Viommu,
+                multifunction: false,
+            })
+        );
+        assert_eq!(
+            iter.next(),
+            Some(UsedSlot {
+                addr: PciAddress {
+                    bus: pci0,
+                    device: 6,
+                    function: 0
+                },
+                device: Device::Bridge(Bus::Pci(99)),
+                multifunction: false,
+            })
+        );
+        assert_eq!(
+            iter.next(),
+            Some(UsedSlot {
+                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..6348ffc
--- /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::{DeviceLayout, Function, Slot, UsedSlot, UsedSlotIterator};
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..5be601e
--- /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.to_id().replace("pve.root-port", "ich9-pcie-port-");
+        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] 11+ messages in thread

* [PATCH pve-qemu-server-rs 2/9] pci: add machine abstraction and PCI bridge generation
  2026-09-22 10:55 [RFC proxmox-perl-rs/qemu-server/qemu-server-rs 0/9] pci-handling rewrite (part 1) Dominik Csapak
  2026-09-22 10:55 ` [PATCH pve-qemu-server-rs 1/9] add pve-qemu-server-pci crate for guest PCI address generation Dominik Csapak
@ 2026-09-22 10:55 ` Dominik Csapak
  2026-09-22 10:55 ` [PATCH pve-qemu-server-rs 3/9] pci: layout: add v2 PCI and PCIe layouts Dominik Csapak
                   ` (7 subsequent siblings)
  9 siblings, 0 replies; 11+ messages in thread
From: Dominik Csapak @ 2026-09-22 10:55 UTC (permalink / raw)
  To: pve-devel

Besides the address of a single device, callers also need the list of
bridge devices a guest has to be started with. Which bridges those are
depends on more than one property of the guest config, so pass them in
as a `Machine` struct rather than as a growing list of parameters. It
can be extended with the layout variant and other guest wide settings
later on, without touching every call site again.

The bridge list itself follows the Perl implementation: q35 machines
already define the bridges up to three in their machine definition,
pci.3 is only needed for virtio-scsi-single, and pci.4 only once a
SCSI controller above index one exists or the machine version is new
enough to always include it.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 pve-qemu-server-pci/Cargo.toml           |   1 +
 pve-qemu-server-pci/src/layout/legacy.rs | 141 ++++++++++++++++++++++-
 pve-qemu-server-pci/src/lib.rs           |  18 +++
 pve-qemu-server-pci/src/machine.rs       |  99 ++++++++++++++++
 4 files changed, 255 insertions(+), 4 deletions(-)
 create mode 100644 pve-qemu-server-pci/src/machine.rs

diff --git a/pve-qemu-server-pci/Cargo.toml b/pve-qemu-server-pci/Cargo.toml
index d3f4401..60c4ed1 100644
--- a/pve-qemu-server-pci/Cargo.toml
+++ b/pve-qemu-server-pci/Cargo.toml
@@ -10,6 +10,7 @@ exclude.workspace = true
 rust-version.workspace = true
 
 [dependencies]
+log.workspace = true
 strum.workspace = true
 
 pve-api-types.workspace = true
diff --git a/pve-qemu-server-pci/src/layout/legacy.rs b/pve-qemu-server-pci/src/layout/legacy.rs
index 40b3265..3f544bc 100644
--- a/pve-qemu-server-pci/src/layout/legacy.rs
+++ b/pve-qemu-server-pci/src/layout/legacy.rs
@@ -2,7 +2,7 @@ use pve_api_types::ClusterResourceHostArch;
 
 use crate::constants::BRIDGE_SLOT_NUM;
 use crate::layout::bridge_slots;
-use crate::{Bus, Device, DeviceLayout, Function, PciConfigError, Slot};
+use crate::{Bus, Device, DeviceLayout, Function, Machine, PciConfigError, Slot};
 
 const PCI_1: [Slot; BRIDGE_SLOT_NUM] = bridge_slots(&[
     single_device!(Net(6)),
@@ -369,6 +369,65 @@ pub(crate) fn print_pcie_root_port(index: u8) -> Result<String, PciConfigError>
     Ok(res)
 }
 
+/// Looks up the PCI bridge a device sits on, see
+/// [`crate::get_pci_bridge_for_device`].
+pub(crate) fn get_pci_bridge_for_device(id: &str) -> Option<u8> {
+    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()?;
+    match addr.bus {
+        Bus::Pci(i) => Some(i),
+        _ => None,
+    }
+}
+
+/// Renders the bridge devices a guest needs, see
+/// [`crate::get_pci_bridges`].
+pub(crate) fn get_pci_bridges(machine: &Machine) -> Vec<String> {
+    // at most four bridges, each contributing a flag and its value
+    let mut res = Vec::with_capacity(8);
+
+    // 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 = machine.version_at_least(11, 1, 0) || machine.max_scsihw > 1;
+
+    for i in 1..=4 {
+        // q35.cfg already includes the bridges up to three
+        if i < 4 && machine.q35 {
+            continue;
+        }
+
+        if i == 3 && !machine.virtio_scsi_single() {
+            continue;
+        }
+
+        if i == 4 && !include_pci4 {
+            continue;
+        }
+
+        let id = if i == 2 && machine.legacy_igd {
+            format!("pci.{i}-igd")
+        } else {
+            format!("pci.{i}")
+        };
+
+        let addr = match print_pci_addr(&id, machine.arch) {
+            Ok(addr) => addr,
+            Err(err) => {
+                log::warn!("could not find address for bridge {id}: {err}");
+                continue;
+            }
+        };
+        res.push("-device".to_string());
+        res.push(format!("pci-bridge,id=pci.{i},chassis_nr={i}{addr}"));
+    }
+
+    res
+}
+
 #[cfg(test)]
 pub(crate) mod test {
     use pve_api_types::ClusterResourceHostArch;
@@ -376,10 +435,10 @@ pub(crate) mod test {
     use std::collections::HashSet;
 
     use super::{
-        LEGACY_PCIE_LAYOUT, get_legacy_pci_layout, print_pci_addr, print_pcie_addr,
-        print_pcie_root_port,
+        LEGACY_PCIE_LAYOUT, get_legacy_pci_layout, get_pci_bridge_for_device, get_pci_bridges,
+        print_pci_addr, print_pcie_addr, print_pcie_root_port,
     };
-    use crate::{Bus, Device, DeviceLayout, PciAddress};
+    use crate::{Bus, Device, DeviceLayout, Machine, PciAddress};
 
     use crate::constants::MAX_HOSTPCI_DEVICES;
 
@@ -652,4 +711,78 @@ pub(crate) mod test {
         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!(get_pci_bridge_for_device(input), Some(bus), "for {input}");
+        }
+
+        assert_eq!(get_pci_bridge_for_device("no-such-device"), None);
+    }
+
+    fn machine(q35: bool, scsihw: Option<&str>, max_scsihw: u8, legacy_igd: bool) -> Machine {
+        Machine::new(
+            "x86_64",
+            q35,
+            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_bridges() {
+        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 = |machine: Machine, expected: &[&[String; 2]]| {
+            let expected: Vec<String> = expected.iter().flat_map(|b| b.iter().cloned()).collect();
+            assert_eq!(get_pci_bridges(&machine), expected);
+        };
+
+        test(machine(false, None, 0, false), &[&pci1, &pci2]);
+
+        // q35 already brings the bridges up to three along
+        test(machine(true, None, 0, false), &[]);
+
+        // every SCSI disk gets its own controller on pci.3
+        test(
+            machine(false, Some("virtio-scsi-single"), 0, false),
+            &[&pci1, &pci2, &pci3],
+        );
+
+        // scsihw2 and above live on pci.4
+        test(machine(false, Some("lsi"), 1, false), &[&pci1, &pci2]);
+        test(
+            machine(false, Some("lsi"), 2, false),
+            &[&pci1, &pci2, &pci4],
+        );
+        test(machine(true, Some("lsi"), 2, false), &[&pci4]);
+
+        // pci.4 is always added from machine version 11.1 on
+        let mut new_machine = machine(false, None, 0, false);
+        new_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(machine(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 5e8b8a7..7d0bd92 100644
--- a/pve-qemu-server-pci/src/lib.rs
+++ b/pve-qemu-server-pci/src/lib.rs
@@ -10,6 +10,9 @@
 
 pub mod constants;
 
+mod machine;
+pub use machine::Machine;
+
 mod types;
 use pve_api_types::ClusterResourceHostArch as Arch;
 pub use types::*;
@@ -42,3 +45,18 @@ pub fn print_pcie_addr(id: &str) -> Result<String, PciConfigError> {
 pub fn print_pcie_root_port(index: u8) -> Result<String, PciConfigError> {
     layout::legacy::print_pcie_root_port(index)
 }
+
+/// 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 get_pci_bridge_for_device(id: &str) -> Option<u8> {
+    layout::legacy::get_pci_bridge_for_device(id)
+}
+
+/// Returns the QEMU arguments for all PCI bridges `machine` needs, as
+/// alternating `-device` flags and their values.
+///
+/// Bridges whose address cannot be determined are skipped with a warning
+/// rather than failing the whole guest.
+pub fn get_pci_bridges(machine: &Machine) -> Vec<String> {
+    layout::legacy::get_pci_bridges(machine)
+}
diff --git a/pve-qemu-server-pci/src/machine.rs b/pve-qemu-server-pci/src/machine.rs
new file mode 100644
index 0000000..10ee9db
--- /dev/null
+++ b/pve-qemu-server-pci/src/machine.rs
@@ -0,0 +1,99 @@
+use pve_api_types::{ClusterResourceHostArch as Arch, QemuConfigOstype, QemuConfigScsihw};
+
+/// 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(Clone, Debug)]
+pub struct Machine {
+    pub arch: Arch,
+    pub q35: bool,
+    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 version: (u16, u16, Option<u16>),
+}
+
+impl Machine {
+    /// Builds a [`Machine`] 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,
+        q35: bool,
+        scsihw: Option<String>,
+        max_scsihw: u8,
+        legacy_igd: bool,
+        ostype: Option<String>,
+        major: u16,
+        minor: u16,
+        pve: Option<u16>,
+    ) -> Self {
+        let arch = arch.parse().unwrap_or(Arch::X8664);
+        let scsihw = scsihw.and_then(|hw| hw.parse().ok());
+        let ostype = ostype.and_then(|ostype| ostype.parse().ok());
+        Self {
+            arch,
+            q35,
+            scsihw,
+            max_scsihw,
+            legacy_igd,
+            ostype,
+            version: (major, minor, pve),
+        }
+    }
+
+    /// Whether the guest's root bus is PCIe. True for q35 machines and for
+    /// aarch64, which only has a PCIe host bridge.
+    pub fn is_pcie(&self) -> bool {
+        self.q35 || self.arch == Arch::Aarch64
+    }
+
+    /// 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 being at least any requested
+    /// one, since the plain QEMU version already implies all PVE changes made
+    /// for it.
+    pub fn version_at_least(&self, major: u16, minor: u16, pve: u16) -> bool {
+        if self.version.0 > major {
+            return true;
+        }
+        if self.version.0 < major {
+            return false;
+        }
+
+        if self.version.1 > minor {
+            return true;
+        }
+        if self.version.1 < minor {
+            return false;
+        }
+
+        if let Some(our_pve) = self.version.2 {
+            if our_pve > pve {
+                return true;
+            }
+            if our_pve < pve {
+                return false;
+            }
+        }
+
+        true
+    }
+}
-- 
2.47.3





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

* [PATCH pve-qemu-server-rs 3/9] pci: layout: add v2 PCI and PCIe layouts
  2026-09-22 10:55 [RFC proxmox-perl-rs/qemu-server/qemu-server-rs 0/9] pci-handling rewrite (part 1) Dominik Csapak
  2026-09-22 10:55 ` [PATCH pve-qemu-server-rs 1/9] add pve-qemu-server-pci crate for guest PCI address generation Dominik Csapak
  2026-09-22 10:55 ` [PATCH pve-qemu-server-rs 2/9] pci: add machine abstraction and PCI bridge generation Dominik Csapak
@ 2026-09-22 10:55 ` Dominik Csapak
  2026-09-22 10:55 ` [PATCH pve-qemu-server-rs 4/9] fixup! add pve-qemu-server-pci crate for guest PCI address generation Dominik Csapak
                   ` (6 subsequent siblings)
  9 siblings, 0 replies; 11+ messages in thread
From: Dominik Csapak @ 2026-09-22 10:55 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.

These are not wired up to the entry points yet: they are meant for new
guests only, and the machine property selecting them still has to be
added on the qemu-server side.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 pve-qemu-server-pci/src/layout/mod.rs |   2 +
 pve-qemu-server-pci/src/layout/v2.rs  | 344 ++++++++++++++++++++++++++
 pve-qemu-server-pci/src/types/mod.rs  |   3 +
 3 files changed, 349 insertions(+)
 create mode 100644 pve-qemu-server-pci/src/layout/v2.rs

diff --git a/pve-qemu-server-pci/src/layout/mod.rs b/pve-qemu-server-pci/src/layout/mod.rs
index c81d730..0b72abd 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 v2;
+
 /// 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/layout/v2.rs b/pve-qemu-server-pci/src/layout/v2.rs
new file mode 100644
index 0000000..9525492
--- /dev/null
+++ b/pve-qemu-server-pci/src/layout/v2.rs
@@ -0,0 +1,344 @@
+use crate::constants::{
+    BRIDGE_SLOT_NUM, MAX_HOSTPCI_DEVICES, MAX_NET_DEVICES, MAX_SCSI_DEVICES,
+    MAX_VIRTIO_BLK_DEVICES, MAX_VIRTIOFS_DEVICES,
+};
+use crate::layout::bridge_slots;
+use crate::{Bus, Device, DeviceLayout, Function, 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 SYS_BRIDGE_SLOTS: [Slot; BRIDGE_SLOT_NUM] = 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: DeviceLayout = DeviceLayout {
+    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), &SYS_BRIDGE_SLOTS)),
+        Slot::DynamicBridge {
+            kind: PveBridge::VirtioFs,
+            max: MAX_VIRTIOFS_DEVICES,
+        },
+        Slot::DynamicBridge {
+            kind: PveBridge::Net,
+            max: MAX_NET_DEVICES,
+        },
+        Slot::Unused,
+        Slot::DynamicBridge {
+            kind: PveBridge::Scsi,
+            max: MAX_SCSI_DEVICES,
+        },
+        Slot::Unused,
+        Slot::DynamicBridge {
+            kind: PveBridge::VirtioBlk,
+            max: MAX_VIRTIO_BLK_DEVICES,
+        },
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::DynamicBridge {
+            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: DeviceLayout = DeviceLayout {
+    pcie: true,
+    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), &SYS_BRIDGE_SLOTS)),
+        Slot::DynamicBridge {
+            kind: PveBridge::VirtioFs,
+            max: MAX_VIRTIOFS_DEVICES,
+        },
+        Slot::DynamicBridge {
+            kind: PveBridge::Net,
+            max: MAX_NET_DEVICES,
+        },
+        Slot::Unused,
+        Slot::DynamicBridge {
+            kind: PveBridge::Scsi,
+            max: MAX_SCSI_DEVICES,
+        },
+        Slot::Unused,
+        Slot::DynamicBridge {
+            kind: PveBridge::VirtioBlk,
+            max: MAX_VIRTIO_BLK_DEVICES,
+        },
+        Slot::Unused,
+        Slot::Unused,
+        Slot::Unused,
+        Slot::DynamicBridge {
+            kind: PveBridge::Hostpci,
+            max: MAX_HOSTPCI_DEVICES,
+        },
+        Slot::Unused,
+        Slot::Multi(&[
+            Function::RootPort(0, Device::Hostpcie(0)),
+            Function::RootPort(1, Device::Hostpcie(1)),
+            Function::RootPort(2, Device::Hostpcie(2)),
+            Function::RootPort(3, Device::Hostpcie(3)),
+            Function::RootPort(4, Device::Hostpcie(4)),
+            Function::RootPort(5, Device::Hostpcie(5)),
+            Function::RootPort(6, Device::Hostpcie(6)),
+            Function::RootPort(7, Device::Hostpcie(7)),
+        ]),
+        Slot::Multi(&[
+            Function::RootPort(8, Device::Hostpcie(8)),
+            Function::RootPort(9, Device::Hostpcie(9)),
+            Function::RootPort(10, Device::Hostpcie(10)),
+            Function::RootPort(11, Device::Hostpcie(11)),
+            Function::RootPort(12, Device::Hostpcie(12)),
+            Function::RootPort(13, Device::Hostpcie(13)),
+            Function::RootPort(14, Device::Hostpcie(14)),
+            Function::RootPort(15, Device::Hostpcie(15)),
+        ]),
+    ]),
+};
+
+#[cfg(test)]
+mod test {
+    use std::collections::{HashMap, HashSet};
+
+    use strum::VariantArray;
+
+    use super::{PCI_LAYOUT, PCIE_LAYOUT};
+
+    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, DeviceLayout, 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: &DeviceLayout, 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::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: &DeviceLayout, 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)]);
+    }
+}
diff --git a/pve-qemu-server-pci/src/types/mod.rs b/pve-qemu-server-pci/src/types/mod.rs
index 6348ffc..7ed27e6 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] 11+ messages in thread

* [PATCH pve-qemu-server-rs 4/9] fixup! add pve-qemu-server-pci crate for guest PCI address generation
  2026-09-22 10:55 [RFC proxmox-perl-rs/qemu-server/qemu-server-rs 0/9] pci-handling rewrite (part 1) Dominik Csapak
                   ` (2 preceding siblings ...)
  2026-09-22 10:55 ` [PATCH pve-qemu-server-rs 3/9] pci: layout: add v2 PCI and PCIe layouts Dominik Csapak
@ 2026-09-22 10:55 ` Dominik Csapak
  2026-09-22 10:55 ` [PATCH proxmox-perl-rs 5/9] pve: add bindings for `pve-qemu-server-pci` crate Dominik Csapak
                   ` (5 subsequent siblings)
  9 siblings, 0 replies; 11+ messages in thread
From: Dominik Csapak @ 2026-09-22 10:55 UTC (permalink / raw)
  To: pve-devel

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

diff --git a/pve-qemu-server-pci/src/layout/legacy.rs b/pve-qemu-server-pci/src/layout/legacy.rs
index 3f544bc..d243a6b 100644
--- a/pve-qemu-server-pci/src/layout/legacy.rs
+++ b/pve-qemu-server-pci/src/layout/legacy.rs
@@ -251,7 +251,7 @@ pub(crate) const LEGACY_PCIE_LAYOUT: DeviceLayout = DeviceLayout {
 /// 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 shared with
+/// Only contains the `hostpci` slots, everything else is handled by
 /// [`LEGACY_PCIE_LAYOUT`].
 pub(crate) const LEGACY_PCIE_LAYOUT_WIN7: DeviceLayout = DeviceLayout {
     pcie: true,
-- 
2.47.3





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

* [PATCH proxmox-perl-rs 5/9] pve: add bindings for `pve-qemu-server-pci` crate
  2026-09-22 10:55 [RFC proxmox-perl-rs/qemu-server/qemu-server-rs 0/9] pci-handling rewrite (part 1) Dominik Csapak
                   ` (3 preceding siblings ...)
  2026-09-22 10:55 ` [PATCH pve-qemu-server-rs 4/9] fixup! add pve-qemu-server-pci crate for guest PCI address generation Dominik Csapak
@ 2026-09-22 10:55 ` Dominik Csapak
  2026-09-22 10:55 ` [PATCH proxmox-perl-rs 6/9] pve: pci bindings: add bindings for the `Machine` struct Dominik Csapak
                   ` (4 subsequent siblings)
  9 siblings, 0 replies; 11+ messages in thread
From: Dominik Csapak @ 2026-09-22 10:55 UTC (permalink / raw)
  To: pve-devel

These provide bindings for using the rust pci code in PVE's qemu-server
perl code. For now a very limited set of functions that live in rust.

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/mod.rs | 50 ++++++++++++++++++++++++++++++++++
 4 files changed, 55 insertions(+)
 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..dbffb38 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 \
 	  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..2d88580 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;
+
 pub mod resource_scheduling;
 
 mod tfa;
diff --git a/pve-rs/src/bindings/pci/mod.rs b/pve-rs/src/bindings/pci/mod.rs
new file mode 100644
index 0000000..9b3ad9d
--- /dev/null
+++ b/pve-rs/src/bindings/pci/mod.rs
@@ -0,0 +1,50 @@
+#[perlmod::package(name = "PVE::RS::PCI", lib = "pve_rs")]
+pub mod pve_rs_pci {
+    //! The `PVE::RS::PCI` package.
+    //!
+    //! Provides bindings for the [`pve-qemu-server-pci`] crate.
+
+    use anyhow::{Error, format_err};
+
+    pub use pve_qemu_server_pci::Machine;
+
+    use crate::bindings::pci::machine::pve_rs_pci_machine::MachineWrap;
+
+    #[export]
+    /// Prints the PCI address for a guest from the given ID and architecture.
+    pub fn print_pci_addr(id: &str, arch: &str) -> Result<String, Error> {
+        pve_qemu_server_pci::print_pci_addr(id, arch)
+            .map_err(|err| format_err!("can't find pci address for {id}: {:?}", err))
+    }
+
+    #[export]
+    /// Prints the PCIe address for a guest from the given ID.
+    pub fn print_pcie_addr(id: &str) -> Result<String, Error> {
+        pve_qemu_server_pci::print_pcie_addr(id)
+            .map_err(|err| format_err!("can't find pcie address for {id}: {:?}", err))
+    }
+
+    #[export]
+    /// Prints the PCIe root port for a guest from the given index.
+    pub fn print_pcie_root_port(i: u8) -> Result<String, Error> {
+        pve_qemu_server_pci::print_pcie_root_port(i)
+            .map_err(|err| format_err!("can't find pcie root port {i}: {:?}", err))
+    }
+
+    #[export]
+    /// Get PCI bridge number the given device sits on (if it exists)
+    pub fn get_pci_bridge_for_device(id: &str) -> Option<u8> {
+        pve_qemu_server_pci::get_pci_bridge_for_device(id)
+    }
+
+    #[export]
+    /// Get the necessary PCI bridges for a guest from the given options.
+    pub fn get_pci_bridges(
+        arch: &str,
+        q35: bool,
+        virtio_scsi_single: bool,
+        legacy_igd: bool,
+    ) -> Vec<String> {
+        pve_qemu_server_pci::get_pci_bridges(arch, q35, virtio_scsi_single, legacy_igd)
+    }
+}
-- 
2.47.3





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

* [PATCH proxmox-perl-rs 6/9] pve: pci bindings: add bindings for the `Machine` struct
  2026-09-22 10:55 [RFC proxmox-perl-rs/qemu-server/qemu-server-rs 0/9] pci-handling rewrite (part 1) Dominik Csapak
                   ` (4 preceding siblings ...)
  2026-09-22 10:55 ` [PATCH proxmox-perl-rs 5/9] pve: add bindings for `pve-qemu-server-pci` crate Dominik Csapak
@ 2026-09-22 10:55 ` Dominik Csapak
  2026-09-22 10:55 ` [PATCH qemu-server 7/9] pci: use PVE::RS::PCI bindings Dominik Csapak
                   ` (3 subsequent siblings)
  9 siblings, 0 replies; 11+ messages in thread
From: Dominik Csapak @ 2026-09-22 10:55 UTC (permalink / raw)
  To: pve-devel

This is a better way to convey information necessary for some functions,
so make use of it in the bindings.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 pve-rs/Makefile                    |  1 +
 pve-rs/src/bindings/pci/machine.rs | 45 ++++++++++++++++++++++++++++++
 pve-rs/src/bindings/pci/mod.rs     | 14 ++++------
 3 files changed, 52 insertions(+), 8 deletions(-)
 create mode 100644 pve-rs/src/bindings/pci/machine.rs

diff --git a/pve-rs/Makefile b/pve-rs/Makefile
index dbffb38..20a7d24 100644
--- a/pve-rs/Makefile
+++ b/pve-rs/Makefile
@@ -30,6 +30,7 @@ PERLMOD_PACKAGES := \
 	  PVE::RS::NVML \
 	  PVE::RS::OCI \
 	  PVE::RS::PCI \
+	  PVE::RS::PCI::Machine \
 	  PVE::RS::OpenId \
 	  PVE::RS::ResourceScheduling::Static \
 	  PVE::RS::ResourceScheduling::Dynamic \
diff --git a/pve-rs/src/bindings/pci/machine.rs b/pve-rs/src/bindings/pci/machine.rs
new file mode 100644
index 0000000..d9efc32
--- /dev/null
+++ b/pve-rs/src/bindings/pci/machine.rs
@@ -0,0 +1,45 @@
+#[perlmod::package(name = "PVE::RS::PCI::Machine", lib = "pve_rs")]
+pub mod pve_rs_pci_machine {
+    //! The `PVE::RS::PCI::Machine` package.
+    //!
+    //! Provides bindings for the `Machine` struct of the [`pve-qemu-server-pci`] crate.
+
+    use std::sync::RwLock;
+
+    use anyhow::Error;
+
+    use perlmod::Value;
+
+    use pve_qemu_server_pci::Machine;
+
+    perlmod::declare_magic!(Box<MachineWrap>: &MachineWrap as "PVE::RS::PCI::Machine");
+
+    /// Wraps the `Machine` struct behind an RwLock since most accesses are reads.
+    pub struct MachineWrap {
+        /// test
+        pub inner: RwLock<Machine>,
+    }
+
+    #[export(raw_return)]
+    #[allow(clippy::too_many_arguments)]
+    /// Create a instance of the `MachineWrap` struct
+    pub fn new(
+        #[raw] class: Value,
+        arch: String,
+        q35: bool,
+        scsihw: Option<String>,
+        max_scsihw: u8,
+        legacy_igd: bool,
+        ostype: Option<String>,
+        major: u16,
+        minor: u16,
+        pve: Option<u16>,
+    ) -> Result<Value, Error> {
+        let machine = Machine::new(
+            &arch, q35, scsihw, max_scsihw, legacy_igd, ostype, major, minor, pve,
+        );
+        Ok(
+            perlmod::instantiate_magic!(&class, MAGIC => Box::new(MachineWrap { inner: RwLock::new(machine) })),
+        )
+    }
+}
diff --git a/pve-rs/src/bindings/pci/mod.rs b/pve-rs/src/bindings/pci/mod.rs
index 9b3ad9d..a036704 100644
--- a/pve-rs/src/bindings/pci/mod.rs
+++ b/pve-rs/src/bindings/pci/mod.rs
@@ -1,3 +1,5 @@
+pub(crate) mod machine;
+
 #[perlmod::package(name = "PVE::RS::PCI", lib = "pve_rs")]
 pub mod pve_rs_pci {
     //! The `PVE::RS::PCI` package.
@@ -38,13 +40,9 @@ pub mod pve_rs_pci {
     }
 
     #[export]
-    /// Get the necessary PCI bridges for a guest from the given options.
-    pub fn get_pci_bridges(
-        arch: &str,
-        q35: bool,
-        virtio_scsi_single: bool,
-        legacy_igd: bool,
-    ) -> Vec<String> {
-        pve_qemu_server_pci::get_pci_bridges(arch, q35, virtio_scsi_single, legacy_igd)
+    /// Get the necessary PCI bridges for a guest from the given config.
+    pub fn get_pci_bridges(#[try_from_ref] machine: &MachineWrap) -> Vec<String> {
+        let machine = machine.inner.read().unwrap();
+        pve_qemu_server_pci::get_pci_bridges(&machine)
     }
 }
-- 
2.47.3





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

* [PATCH qemu-server 7/9] pci: use PVE::RS::PCI bindings
  2026-09-22 10:55 [RFC proxmox-perl-rs/qemu-server/qemu-server-rs 0/9] pci-handling rewrite (part 1) Dominik Csapak
                   ` (5 preceding siblings ...)
  2026-09-22 10:55 ` [PATCH proxmox-perl-rs 6/9] pve: pci bindings: add bindings for the `Machine` struct Dominik Csapak
@ 2026-09-22 10:55 ` Dominik Csapak
  2026-09-22 10:55 ` [PATCH qemu-server 8/9] helpers: factor out the version parts parsing Dominik Csapak
                   ` (2 subsequent siblings)
  9 siblings, 0 replies; 11+ messages in thread
From: Dominik Csapak @ 2026-09-22 10:55 UTC (permalink / raw)
  To: pve-devel

This makes use of the rust PCI bindings, where the list of addresses
live. With that, the duplicated id test is no longer necessary (and
possible), this is now checked by the rust crate itself.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 src/PVE/QemuServer/PCI.pm       | 272 ++------------------------------
 src/test/Makefile               |   5 +-
 src/test/run_pci_addr_checks.pl | 141 -----------------
 3 files changed, 10 insertions(+), 408 deletions(-)
 delete mode 100755 src/test/run_pci_addr_checks.pl

diff --git a/src/PVE/QemuServer/PCI.pm b/src/PVE/QemuServer/PCI.pm
index d7dc6121..c3f4e918 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;
 use PVE::SysFSTools;
 use PVE::Tools;
 
@@ -152,264 +153,28 @@ 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;
+    return PVE::RS::PCI::get_pci_bridge_for_device($id);
 }
 
 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;
+    return PVE::RS::PCI::print_pci_addr($id, $arch);
 }
 
 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;
+    return PVE::RS::PCI::print_pcie_addr($id);
 }
 
-# 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;
+    return PVE::RS::PCI::print_pcie_root_port($i);
 }
 
 # returns the parsed pci config but parses the 'host' part into
@@ -886,17 +651,11 @@ sub reserve_pci_usage {
 sub get_pci_bridges {
     my ($conf, $arch, $q35, $max_scsihw, $version_guard) = @_;
 
-    my $bridges = {
-        # 0 => 1, always present
-        1 => 1,
-        2 => 1,
-    };
-
-    $bridges->{3} = 1 if ($conf->{scsihw} // '') =~ m/^virtio-scsi-single/;
+    my $virtio_scsi_single = ($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);
+    my $include_pci4 = $max_scsihw > 1 || $version_guard->(11, 1);
 
     # use cheap legacy igd check instead of a full parse_hostpci
     my $legacy_igd = 0;
@@ -910,21 +669,8 @@ sub get_pci_bridges {
         }
     }
 
-    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;
+    return PVE::RS::PCI::get_pci_bridges($arch, $q35, $virtio_scsi_single, $legacy_igd,
+        $include_pci4);
 }
 
 1;
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/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] 11+ messages in thread

* [PATCH qemu-server 8/9] helpers: factor out the version parts parsing
  2026-09-22 10:55 [RFC proxmox-perl-rs/qemu-server/qemu-server-rs 0/9] pci-handling rewrite (part 1) Dominik Csapak
                   ` (6 preceding siblings ...)
  2026-09-22 10:55 ` [PATCH qemu-server 7/9] pci: use PVE::RS::PCI bindings Dominik Csapak
@ 2026-09-22 10:55 ` Dominik Csapak
  2026-09-22 10:55 ` [PATCH qemu-server 9/9] pci: bridges: use the rust `Machine` struct to pass parameters Dominik Csapak
  2026-09-22 11:06 ` [RFC proxmox-perl-rs/qemu-server/qemu-server-rs 0/9] pci-handling rewrite (part 1) Dominik Csapak
  9 siblings, 0 replies; 11+ messages in thread
From: Dominik Csapak @ 2026-09-22 10:55 UTC (permalink / raw)
  To: pve-devel

this can be useful on it's own when needing to pass the individual parts
of the version to something else.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 src/PVE/QemuServer/Helpers.pm | 22 ++++++++++++++++++++--
 1 file changed, 20 insertions(+), 2 deletions(-)

diff --git a/src/PVE/QemuServer/Helpers.pm b/src/PVE/QemuServer/Helpers.pm
index dd17eef5..1e55ad6c 100644
--- a/src/PVE/QemuServer/Helpers.pm
+++ b/src/PVE/QemuServer/Helpers.pm
@@ -21,6 +21,7 @@ our @EXPORT_OK = qw(
     parse_number_sets
     windows_version
     get_host_arch
+    version_parts
 );
 
 my $nodename = PVE::INotify::nodename();
@@ -224,11 +225,28 @@ sub vm_running_locally {
     return instance_running_locally($pidfile);
 }
 
+# Parses a version string like X.Y.Z-pveW and returns (X, Y, Z, W) as numeric values.
+sub version_parts {
+    my ($verstr) = @_;
+
+    if ($verstr =~ m/^(\d+)\.(\d+)(?:\.(\d+))?(?:\+pve(\d+))?/) {
+        my $major = $1 + 0;
+        my $minor = $2 + 0;
+        my $patch = $3;
+        $patch += 0 if defined($patch);
+        my $pve = $4;
+        $pve += 0 if defined($pve);
+        return ($major, $minor, $patch, $pve);
+    }
+
+    return;
+}
+
 sub min_version {
     my ($verstr, $major, $minor, $pve) = @_;
 
-    if ($verstr =~ m/^(\d+)\.(\d+)(?:\.(\d+))?(?:\+pve(\d+))?/) {
-        return 1 if version_cmp($1, $major, $2, $minor, $4, $pve) >= 0;
+    if (my ($got_major, $got_minor, $got_patch, $got_pve) = version_parts($verstr)) {
+        return 1 if version_cmp($got_major, $major, $got_minor, $minor, $got_pve, $pve) >= 0;
         return 0;
     }
 
-- 
2.47.3





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

* [PATCH qemu-server 9/9] pci: bridges: use the rust `Machine` struct to pass parameters
  2026-09-22 10:55 [RFC proxmox-perl-rs/qemu-server/qemu-server-rs 0/9] pci-handling rewrite (part 1) Dominik Csapak
                   ` (7 preceding siblings ...)
  2026-09-22 10:55 ` [PATCH qemu-server 8/9] helpers: factor out the version parts parsing Dominik Csapak
@ 2026-09-22 10:55 ` Dominik Csapak
  2026-09-22 11:06 ` [RFC proxmox-perl-rs/qemu-server/qemu-server-rs 0/9] pci-handling rewrite (part 1) Dominik Csapak
  9 siblings, 0 replies; 11+ messages in thread
From: Dominik Csapak @ 2026-09-22 10:55 UTC (permalink / raw)
  To: pve-devel

This holds some general info about the guest config and is intended
to be constructed once and passed to the individual functions that need
to access them in rust, similar to the Cfg2Cmd class for perl.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 src/PVE/QemuServer.pm                    |  4 ++-
 src/PVE/QemuServer/PCI.pm                | 32 ++++++++++++++++++------
 src/test/TestsCommon/CommandLineMocks.pm |  3 +++
 3 files changed, 30 insertions(+), 9 deletions(-)

diff --git a/src/PVE/QemuServer.pm b/src/PVE/QemuServer.pm
index c28668c1..0c2da1ab 100644
--- a/src/PVE/QemuServer.pm
+++ b/src/PVE/QemuServer.pm
@@ -3289,7 +3289,9 @@ sub config_to_command {
     }
 
     my $max_scsihw = PVE::QemuServer::DriveDevice::get_max_scsihw_index($conf);
-    if (my $bridges = get_pci_bridges($conf, $arch, $q35, $max_scsihw, $version_guard)) {
+    my $pci_machine = PVE::QemuServer::PCI::pci_machine($conf, $arch, $q35, $max_scsihw);
+
+    if (my $bridges = get_pci_bridges($pci_machine)) {
         push @$devices, $bridges->@*;
     }
 
diff --git a/src/PVE/QemuServer/PCI.pm b/src/PVE/QemuServer/PCI.pm
index c3f4e918..19250d8a 100644
--- a/src/PVE/QemuServer/PCI.pm
+++ b/src/PVE/QemuServer/PCI.pm
@@ -8,6 +8,7 @@ use IO::File;
 use PVE::JSONSchema;
 use PVE::Mapping::PCI;
 use PVE::RS::PCI;
+use PVE::RS::PCI::Machine;
 use PVE::SysFSTools;
 use PVE::Tools;
 
@@ -649,15 +650,14 @@ sub reserve_pci_usage {
 # 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) = @_;
+    my ($pci_machine) = @_;
 
-    my $virtio_scsi_single = ($conf->{scsihw} // '') =~ m/^virtio-scsi-single/;
+    return PVE::RS::PCI::get_pci_bridges($pci_machine);
+}
 
-    # some scsi controllers can only have 7 scsi disks per controller,
-    # so scsi14 and upwards need scsihw2,3,4 which live on bridge 4
-    my $include_pci4 = $max_scsihw > 1 || $version_guard->(11, 1);
+sub pci_machine {
+    my ($conf, $arch, $q35, $max_scsihw) = @_;
 
-    # use cheap legacy igd check instead of a full parse_hostpci
     my $legacy_igd = 0;
     for (my $i = 0; $i < $MAX_HOSTPCI_DEVICES; $i++) {
         next if !defined($conf->{"hostpci$i"});
@@ -669,8 +669,24 @@ sub get_pci_bridges {
         }
     }
 
-    return PVE::RS::PCI::get_pci_bridges($arch, $q35, $virtio_scsi_single, $legacy_igd,
-        $include_pci4);
+    my $machine_type = PVE::QemuServer::Machine::get_vm_machine($conf);
+    my $machine_version = PVE::QemuServer::Machine::extract_version(
+        $machine_type,
+        PVE::QemuServer::Helpers::kvm_user_version(),
+    );
+    my ($major, $minor, $patch, $pve) = PVE::QemuServer::Helpers::version_parts($machine_version);
+
+    return PVE::RS::PCI::Machine->new(
+        $arch // '',
+        $q35,
+        $conf->{scsihw},
+        $max_scsihw + 0,
+        $legacy_igd,
+        $conf->{ostype},
+        $major,
+        $minor,
+        $pve,
+    );
 }
 
 1;
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;
-- 
2.47.3





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

* Re: [RFC proxmox-perl-rs/qemu-server/qemu-server-rs 0/9] pci-handling rewrite (part 1)
  2026-09-22 10:55 [RFC proxmox-perl-rs/qemu-server/qemu-server-rs 0/9] pci-handling rewrite (part 1) Dominik Csapak
                   ` (8 preceding siblings ...)
  2026-09-22 10:55 ` [PATCH qemu-server 9/9] pci: bridges: use the rust `Machine` struct to pass parameters Dominik Csapak
@ 2026-09-22 11:06 ` Dominik Csapak
  9 siblings, 0 replies; 11+ messages in thread
From: Dominik Csapak @ 2026-09-22 11:06 UTC (permalink / raw)
  To: pve-devel

meh, accidentally included a fixup commit,
should work regardless^^

On 9/22/26 12:56 PM, Dominik Csapak wrote:
> # Motivation
> 
> We want to extend the PCI layout mechanism to be easily extendable, especially
> for NUMA awareness and replicating host-toplogy (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 onto new addresses on there, using whatever free
> address there exists.
> 
> This lead 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 `Machine` 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 (partially this series)
> 
> This would introduce a new layout ("v2") that can be used in an opt-in manner,
> and is vastly more logically constructed, which makes it easy to extend and
> argue about. See the "preview" patch (last of pve-qemu-server-rs) for how this
> layout can look like.
> 
> This part would also entail restructuring the addr calls in qemu-server's perl
> code, so to only have a single entry point ("address" for example) instead of
> splitting between 'print_pci_addr' and 'print_pcie_addr'.
> 
> In this phase we could also emit the commandline for devices that are currently
> in the q35 config files read with readconfig.
> 
> ## Phase 3 - more perl to rust code port
> 
> To better work with the layout, we'd need to pull more code from qemu-server
> into rust, for this plan mostly from PCI.pm and USB.pm but maybe some other
> helpers too (version comparsion comes to mind).
> 
> Here we could start using the schema from pve-api-types to parse e.g. the
> config rust-side, but the schema still has to live perl side (ofc).
> 
> ## Phase 4 - Host and NUMA layouts
> 
> 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
> toplogy 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.
> * There are still some rough edges (e.g. hardcoding the perl config max values
>    in rust again), but it should work as intended and it passes our qemu-server
>    regression tests.
> * 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 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 to the layouting changes in perl, or keep some parts in
>    perl, that's fine with me too and I'll change my efforts accordingly
> 
> 0: https://bugzilla.proxmox.com/show_bug.cgi?id=7283
> 1: https://lore.proxmox.com/pve-devel/20260914085725.1299009-1-d.csapak@proxmox.com/
> 
> 
> pve-qemu-server-rs:
> 
> Dominik Csapak (4):
>    add pve-qemu-server-pci crate for guest PCI address generation
>    pci: add machine abstraction and PCI bridge generation
>    pci: layout: add v2 PCI and PCIe layouts
>    fixup! add pve-qemu-server-pci crate for guest PCI address generation
> 
> 
> proxmox-perl-rs:
> 
> Dominik Csapak (2):
>    pve: add bindings for `pve-qemu-server-pci` crate
>    pve: pci bindings: add bindings for the `Machine` struct
> 
>   pve-rs/Cargo.toml                  |  1 +
>   pve-rs/Makefile                    |  2 ++
>   pve-rs/src/bindings/mod.rs         |  3 ++
>   pve-rs/src/bindings/pci/machine.rs | 45 ++++++++++++++++++++++++++++
>   pve-rs/src/bindings/pci/mod.rs     | 48 ++++++++++++++++++++++++++++++
>   5 files changed, 99 insertions(+)
>   create mode 100644 pve-rs/src/bindings/pci/machine.rs
>   create mode 100644 pve-rs/src/bindings/pci/mod.rs
> 
> 
> qemu-server:
> 
> Dominik Csapak (3):
>    pci: use PVE::RS::PCI bindings
>    helpers: factor out the version parts parsing
>    pci: bridges: use the rust `Machine` struct to pass parameters
> 
>   src/PVE/QemuServer.pm                    |   4 +-
>   src/PVE/QemuServer/Helpers.pm            |  22 +-
>   src/PVE/QemuServer/PCI.pm                | 296 +++--------------------
>   src/test/Makefile                        |   5 +-
>   src/test/TestsCommon/CommandLineMocks.pm |   3 +
>   src/test/run_pci_addr_checks.pl          | 141 -----------
>   6 files changed, 56 insertions(+), 415 deletions(-)
>   delete mode 100755 src/test/run_pci_addr_checks.pl
> 
> 
> Summary over all repositories:
>    11 files changed, 155 insertions(+), 415 deletions(-)
> 





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

end of thread, other threads:[~2026-09-22 11:06 UTC | newest]

Thread overview: 11+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-09-22 10:55 [RFC proxmox-perl-rs/qemu-server/qemu-server-rs 0/9] pci-handling rewrite (part 1) Dominik Csapak
2026-09-22 10:55 ` [PATCH pve-qemu-server-rs 1/9] add pve-qemu-server-pci crate for guest PCI address generation Dominik Csapak
2026-09-22 10:55 ` [PATCH pve-qemu-server-rs 2/9] pci: add machine abstraction and PCI bridge generation Dominik Csapak
2026-09-22 10:55 ` [PATCH pve-qemu-server-rs 3/9] pci: layout: add v2 PCI and PCIe layouts Dominik Csapak
2026-09-22 10:55 ` [PATCH pve-qemu-server-rs 4/9] fixup! add pve-qemu-server-pci crate for guest PCI address generation Dominik Csapak
2026-09-22 10:55 ` [PATCH proxmox-perl-rs 5/9] pve: add bindings for `pve-qemu-server-pci` crate Dominik Csapak
2026-09-22 10:55 ` [PATCH proxmox-perl-rs 6/9] pve: pci bindings: add bindings for the `Machine` struct Dominik Csapak
2026-09-22 10:55 ` [PATCH qemu-server 7/9] pci: use PVE::RS::PCI bindings Dominik Csapak
2026-09-22 10:55 ` [PATCH qemu-server 8/9] helpers: factor out the version parts parsing Dominik Csapak
2026-09-22 10:55 ` [PATCH qemu-server 9/9] pci: bridges: use the rust `Machine` struct to pass parameters Dominik Csapak
2026-09-22 11:06 ` [RFC proxmox-perl-rs/qemu-server/qemu-server-rs 0/9] pci-handling rewrite (part 1) 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