public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Wolfgang Bumiller <w.bumiller@proxmox.com>
To: Dominik Csapak <d.csapak@proxmox.com>
Cc: pve-devel@lists.proxmox.com
Subject: Re: [PATCH pve-qemu-server-rs v2 1/9] add pve-qemu-server-pci crate for guest PCI address generation
Date: Thu, 15 Oct 2026 14:16:44 +0200	[thread overview]
Message-ID: <ehl5twunzipka5qhmxpl3tyg4c4d537mivdbwrbtlgne6trw7w@m6clmzczvee5> (raw)
In-Reply-To: <20261015081041.743422-2-d.csapak@proxmox.com>

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

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

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

This can also be `Ordering::Relaxed`.

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

A rogue lifetime appears.

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

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

nit: might be nicer to read as:

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

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

-- 




  reply	other threads:[~2026-10-15 12:19 UTC|newest]

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

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=ehl5twunzipka5qhmxpl3tyg4c4d537mivdbwrbtlgne6trw7w@m6clmzczvee5 \
    --to=w.bumiller@proxmox.com \
    --cc=d.csapak@proxmox.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
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