use std::collections::HashSet;
use super::ffi;
use crate::device::readers::windows_gpu_perf::ids::{PciIds, parse_pnp_device_id};
pub const MAX_ADAPTER_ROWS: i32 = 64;
pub fn plausible_adapter_count(count: i32) -> bool {
(1..=MAX_ADAPTER_ROWS).contains(&count)
}
pub fn clamp_scan_count(count: i32) -> i32 {
count.min(MAX_ADAPTER_ROWS)
}
pub fn describe_layout_failure(rows: &[ffi::AdapterInfo]) -> String {
let states: Vec<ffi::RowState> = rows.iter().map(ffi::AdapterInfo::classify).collect();
let count = |wanted: ffi::RowState| states.iter().filter(|state| **state == wanted).count();
let garbled = count(ffi::RowState::Garbled);
let untouched = count(ffi::RowState::Untouched);
let populated = count(ffi::RowState::Populated);
let total = rows.len();
if garbled > 0 {
return format!(
"{garbled} of {total} row(s) hold written bytes that contradict the layout, \
which is what a wrong field offset or stride produces (a declared AdapterInfo \
larger than the driver's also lands here: the driver strides shorter and later \
rows read misaligned)"
);
}
if states.first() == Some(&ffi::RowState::Untouched) {
return format!(
"row 0 still carries the poison pre-fill ({untouched} of {total} row(s) \
untouched): the call wrote nothing at all"
);
}
if populated == 0 {
return format!(
"the driver memset the buffer but populated none of the {total} row(s): no \
adapter was described"
);
}
"the populated rows repeat an iAdapterIndex value, which no healthy enumeration \
produces and a stride mismatch does"
.to_string()
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AdlAdapter {
pub index: i32,
pub bus: i32,
pub device: i32,
pub function: i32,
pub adapter_name: String,
pub pnp_string: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CardGroup {
pub bus: i32,
pub device: i32,
pub function: i32,
pub adapter_name: String,
pub pnp_string: String,
pub indices: Vec<i32>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CardMatch {
pub gpu_index: usize,
pub adl_indices: Vec<i32>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AttributionPlan {
SoleGpu,
PerCard(Vec<CardMatch>),
Decline,
}
pub fn parse_adapters(entries: &[ffi::AdapterInfo]) -> Vec<AdlAdapter> {
entries
.iter()
.map(|entry| AdlAdapter {
index: entry.i_adapter_index,
bus: entry.i_bus_number,
device: entry.i_device_number,
function: entry.i_function_number,
adapter_name: ffi::adl_string(&entry.str_adapter_name)
.unwrap_or_default()
.to_string(),
pnp_string: ffi::adl_string(&entry.str_pnp_string)
.unwrap_or_default()
.to_string(),
})
.collect()
}
fn plausible_bdf(adapter: &AdlAdapter) -> bool {
(0..=255).contains(&adapter.bus)
&& (0..=31).contains(&adapter.device)
&& (0..=7).contains(&adapter.function)
}
fn shares_device_instance(a: &str, b: &str) -> bool {
let (short, long) = if a.len() <= b.len() { (a, b) } else { (b, a) };
let (short, long) = (short.as_bytes(), long.as_bytes());
if !long[..short.len()].eq_ignore_ascii_case(short) {
return false;
}
long.len() == short.len() || long[short.len()] == b'&'
}
pub fn group_by_card(adapters: &[AdlAdapter]) -> Vec<CardGroup> {
let mut groups: Vec<CardGroup> = Vec::new();
let mut paths: Vec<Vec<String>> = Vec::new();
for adapter in adapters {
if !plausible_bdf(adapter) {
continue;
}
let existing = groups.iter().position(|group| {
group.bus == adapter.bus
&& group.device == adapter.device
&& group.function == adapter.function
});
let position = match existing {
Some(position) => {
let group = &mut groups[position];
group.indices.push(adapter.index);
if group.adapter_name.is_empty() && !adapter.adapter_name.is_empty() {
group.adapter_name = adapter.adapter_name.clone();
}
position
}
None => {
groups.push(CardGroup {
bus: adapter.bus,
device: adapter.device,
function: adapter.function,
adapter_name: adapter.adapter_name.clone(),
pnp_string: String::new(),
indices: vec![adapter.index],
});
paths.push(Vec::new());
groups.len() - 1
}
};
if !adapter.pnp_string.is_empty() {
paths[position].push(adapter.pnp_string.clone());
}
}
let mut groups: Vec<CardGroup> = groups
.into_iter()
.zip(paths)
.filter_map(|(mut group, paths)| {
let Some(base) = paths.iter().min_by_key(|path| path.len()) else {
return Some(group);
};
if !paths.iter().all(|path| shares_device_instance(base, path)) {
return None;
}
group.pnp_string = base.clone();
Some(group)
})
.collect();
for group in &mut groups {
group.indices.sort_unstable();
group.indices.dedup();
}
groups.sort_by_key(|group| (group.bus, group.device, group.function));
groups
}
pub fn plan_attribution(gpu_uuids: &[&str], adapters: Option<&[AdlAdapter]>) -> AttributionPlan {
match gpu_uuids.len() {
0 => return AttributionPlan::Decline,
1 => return AttributionPlan::SoleGpu,
_ => {}
}
let Some(adapters) = adapters else {
return AttributionPlan::Decline;
};
let groups = group_by_card(adapters);
if groups.is_empty() {
return AttributionPlan::Decline;
}
let mut assigned: Vec<Option<usize>> = vec![None; gpu_uuids.len()];
for (gpu_index, uuid) in gpu_uuids.iter().enumerate() {
if uuid.is_empty() {
continue;
}
let hits: Vec<usize> = groups
.iter()
.enumerate()
.filter(|(_, group)| {
!group.pnp_string.is_empty() && group.pnp_string.eq_ignore_ascii_case(uuid)
})
.map(|(position, _)| position)
.collect();
if hits.len() == 1 {
assigned[gpu_index] = Some(hits[0]);
}
}
if has_duplicate_assignment(&assigned) {
return AttributionPlan::Decline;
}
let claimed: HashSet<usize> = assigned.iter().flatten().copied().collect();
let gpu_ids: Vec<Option<PciIds>> = gpu_uuids
.iter()
.map(|uuid| parse_pnp_device_id(uuid))
.collect();
let group_ids: Vec<Option<PciIds>> = groups
.iter()
.map(|group| parse_pnp_device_id(&group.pnp_string))
.collect();
let unmatched: Vec<usize> = (0..gpu_uuids.len())
.filter(|&gpu_index| assigned[gpu_index].is_none())
.collect();
for &gpu_index in &unmatched {
let Some(ids) = gpu_ids[gpu_index] else {
continue;
};
let peers = unmatched
.iter()
.filter(|&&other| gpu_ids[other] == Some(ids))
.count();
if peers != 1 {
continue;
}
let candidates: Vec<usize> = (0..groups.len())
.filter(|position| !claimed.contains(position))
.filter(|&position| group_ids[position] == Some(ids))
.collect();
if candidates.len() == 1 {
assigned[gpu_index] = Some(candidates[0]);
}
}
if has_duplicate_assignment(&assigned) {
return AttributionPlan::Decline;
}
let matches: Vec<CardMatch> = assigned
.iter()
.enumerate()
.filter_map(|(gpu_index, group)| {
group.map(|position| CardMatch {
gpu_index,
adl_indices: groups[position].indices.clone(),
})
})
.collect();
if matches.is_empty() {
AttributionPlan::Decline
} else {
AttributionPlan::PerCard(matches)
}
}
fn has_duplicate_assignment(assigned: &[Option<usize>]) -> bool {
let taken: Vec<usize> = assigned.iter().flatten().copied().collect();
let unique: HashSet<usize> = taken.iter().copied().collect();
taken.len() != unique.len()
}
pub fn describe_raw_entry(slot: usize, entry: &ffi::AdapterInfo) -> String {
match entry.classify() {
ffi::RowState::Untouched => {
format!("[{slot}] UNTOUCHED (poison intact, driver never wrote)")
}
ffi::RowState::Blank => format!("[{slot}] BLANK (driver memset, not populated)"),
state => {
let tag = match state {
ffi::RowState::Garbled => "GARBLED ",
_ => "",
};
format!(
"[{slot}] {tag}index={} bus={} device={} function={} vendor={} name={:?} pnp={:?}",
entry.i_adapter_index,
entry.i_bus_number,
entry.i_device_number,
entry.i_function_number,
entry.i_vendor_id,
ffi::adl_string_lossy(&entry.str_adapter_name),
ffi::adl_string_lossy(&entry.str_pnp_string),
)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn adapter(index: i32, bdf: (i32, i32, i32), pnp: &str) -> AdlAdapter {
AdlAdapter {
index,
bus: bdf.0,
device: bdf.1,
function: bdf.2,
adapter_name: "AMD Radeon RX 7900 XTX".to_string(),
pnp_string: pnp.to_string(),
}
}
const DGPU_PNP: &str = r"PCI\VEN_1002&DEV_744C&SUBSYS_0E3A1002&REV_C8\6&2C6B35A1&0&00000019";
const APU_PNP: &str = r"PCI\VEN_1002&DEV_164E&SUBSYS_00000000&REV_C1\4&2FD5AB1F&0&0041";
#[test]
fn several_indices_for_one_card_collapse_to_one_group() {
let adapters = vec![
adapter(2, (3, 0, 0), DGPU_PNP),
adapter(0, (3, 0, 0), DGPU_PNP),
adapter(1, (3, 0, 0), DGPU_PNP),
];
let groups = group_by_card(&adapters);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].indices, vec![0, 1, 2]);
assert_eq!(groups[0].pnp_string, DGPU_PNP);
}
const REAL_8060S_BASE: &str = r"PCI\VEN_1002&DEV_1586&SUBSYS_B0261F4C&REV_C1\4&2368981F&0&0041";
#[test]
fn real_display_output_rows_do_not_dissolve_their_card() {
let mut adapters = vec![adapter(0, (189, 0, 0), REAL_8060S_BASE)];
let suffixed: Vec<String> = (2..=5).map(|n| format!("{REAL_8060S_BASE}&0{n}")).collect();
for (offset, pnp) in suffixed.iter().enumerate() {
adapters.push(adapter(offset as i32 + 1, (189, 0, 0), pnp));
}
let groups = group_by_card(&adapters);
assert_eq!(
groups.len(),
1,
"five ADL rows of one physical card must yield one group"
);
assert_eq!(groups[0].indices, vec![0, 1, 2, 3, 4]);
assert_eq!(groups[0].pnp_string, REAL_8060S_BASE);
}
#[test]
fn the_base_path_wins_however_the_driver_enumerated_the_rows() {
let second = format!("{REAL_8060S_BASE}&02");
let third = format!("{REAL_8060S_BASE}&03");
let adapters = vec![
adapter(1, (189, 0, 0), &second),
adapter(2, (189, 0, 0), &third),
adapter(0, (189, 0, 0), REAL_8060S_BASE),
];
let groups = group_by_card(&adapters);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].indices, vec![0, 1, 2]);
assert_eq!(groups[0].pnp_string, REAL_8060S_BASE);
}
#[test]
fn a_shared_prefix_that_is_not_a_path_boundary_is_still_a_conflict() {
let lookalike = format!("{REAL_8060S_BASE}0");
let adapters = vec![
adapter(0, (189, 0, 0), REAL_8060S_BASE),
adapter(1, (189, 0, 0), &lookalike),
];
assert!(group_by_card(&adapters).is_empty());
}
#[test]
fn display_output_rows_no_longer_block_multi_gpu_attribution() {
let apu_second = format!("{APU_PNP}&02");
let dgpu_second = format!("{DGPU_PNP}&02");
let adapters = vec![
adapter(0, (4, 0, 0), APU_PNP),
adapter(1, (4, 0, 0), &apu_second),
adapter(2, (8, 0, 0), DGPU_PNP),
adapter(3, (8, 0, 0), &dgpu_second),
];
assert_eq!(
plan_attribution(&[DGPU_PNP, APU_PNP], Some(&adapters)),
AttributionPlan::PerCard(vec![
CardMatch {
gpu_index: 0,
adl_indices: vec![2, 3],
},
CardMatch {
gpu_index: 1,
adl_indices: vec![0, 1],
},
])
);
}
#[test]
fn two_cards_stay_two_groups_and_order_is_deterministic() {
let adapters = vec![
adapter(3, (8, 0, 0), DGPU_PNP),
adapter(0, (4, 0, 0), APU_PNP),
adapter(4, (8, 0, 0), DGPU_PNP),
adapter(1, (4, 0, 0), APU_PNP),
];
let groups = group_by_card(&adapters);
assert_eq!(groups.len(), 2);
assert_eq!((groups[0].bus, groups[0].indices.clone()), (4, vec![0, 1]));
assert_eq!((groups[1].bus, groups[1].indices.clone()), (8, vec![3, 4]));
}
#[test]
fn an_implausible_bdf_row_is_excluded_without_poisoning_the_rest() {
let adapters = vec![
adapter(0, (3, 0, 0), DGPU_PNP),
adapter(1, (-1, 900, 3), APU_PNP),
];
let groups = group_by_card(&adapters);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].bus, 3);
}
#[test]
fn a_blank_pnp_on_a_secondary_output_is_backfilled_from_a_sibling() {
let adapters = vec![
AdlAdapter {
pnp_string: String::new(),
..adapter(0, (3, 0, 0), "")
},
adapter(1, (3, 0, 0), DGPU_PNP),
];
let groups = group_by_card(&adapters);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].pnp_string, DGPU_PNP);
}
#[test]
fn a_bdf_group_with_contradictory_pnp_strings_is_dropped_rather_than_merged() {
let adapters = vec![
adapter(0, (0, 0, 0), APU_PNP),
adapter(1, (0, 0, 0), DGPU_PNP),
];
assert!(group_by_card(&adapters).is_empty());
assert_eq!(
plan_attribution(&[DGPU_PNP, APU_PNP], Some(&adapters)),
AttributionPlan::Decline
);
}
#[test]
fn a_case_differing_pnp_string_on_a_sibling_row_is_not_a_conflict() {
let lowered = DGPU_PNP.to_lowercase();
let adapters = vec![
adapter(0, (3, 0, 0), DGPU_PNP),
adapter(1, (3, 0, 0), &lowered),
];
let groups = group_by_card(&adapters);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].indices, vec![0, 1]);
}
#[test]
fn a_single_gpu_attributes_without_adapterinfo_exactly_as_before() {
assert_eq!(
plan_attribution(&["anything"], None),
AttributionPlan::SoleGpu
);
let adapters = vec![adapter(0, (3, 0, 0), DGPU_PNP)];
assert_eq!(
plan_attribution(&[DGPU_PNP], Some(&adapters)),
AttributionPlan::SoleGpu
);
assert_eq!(
plan_attribution(&["AMD-GPU-0"], Some(&adapters)),
AttributionPlan::SoleGpu
);
}
#[test]
fn zero_gpus_and_missing_or_invalid_inventory_decline() {
assert_eq!(plan_attribution(&[], None), AttributionPlan::Decline);
assert_eq!(
plan_attribution(&[DGPU_PNP, APU_PNP], None),
AttributionPlan::Decline
);
let garbage = vec![adapter(0, (-1, 900, 3), DGPU_PNP)];
assert_eq!(
plan_attribution(&[DGPU_PNP, APU_PNP], Some(&garbage)),
AttributionPlan::Decline
);
}
#[test]
fn the_apu_plus_dgpu_laptop_matches_both_cards_exactly() {
let adapters = vec![
adapter(0, (4, 0, 0), APU_PNP),
adapter(1, (4, 0, 0), APU_PNP),
adapter(2, (8, 0, 0), DGPU_PNP),
adapter(3, (8, 0, 0), DGPU_PNP),
];
let uuids = [DGPU_PNP.to_lowercase(), APU_PNP.to_string()];
let uuid_refs: Vec<&str> = uuids.iter().map(String::as_str).collect();
let plan = plan_attribution(&uuid_refs, Some(&adapters));
assert_eq!(
plan,
AttributionPlan::PerCard(vec![
CardMatch {
gpu_index: 0,
adl_indices: vec![2, 3],
},
CardMatch {
gpu_index: 1,
adl_indices: vec![0, 1],
},
])
);
}
#[test]
fn a_gpu_the_inventory_does_not_know_keeps_its_baseline() {
let adapters = vec![adapter(0, (8, 0, 0), DGPU_PNP)];
let plan = plan_attribution(&[DGPU_PNP, "AMD-GPU-1"], Some(&adapters));
assert_eq!(
plan,
AttributionPlan::PerCard(vec![CardMatch {
gpu_index: 0,
adl_indices: vec![0],
}])
);
}
#[test]
fn a_card_matched_only_by_pci_ids_still_matches_when_unambiguous() {
let adapters = vec![
adapter(0, (4, 0, 0), r"PCI\VEN_1002&DEV_164E&SUBSYS_0&REV_C1\OTHER"),
adapter(1, (8, 0, 0), r"PCI\VEN_1002&DEV_744C&SUBSYS_0&REV_C8\OTHER"),
];
let plan = plan_attribution(&[DGPU_PNP, APU_PNP], Some(&adapters));
assert_eq!(
plan,
AttributionPlan::PerCard(vec![
CardMatch {
gpu_index: 0,
adl_indices: vec![1],
},
CardMatch {
gpu_index: 1,
adl_indices: vec![0],
},
])
);
}
#[test]
fn two_identical_cards_with_mismatched_pnp_strings_decline() {
let uuid_a = r"PCI\VEN_1002&DEV_744C&SUBSYS_0\6&AAAA&0&19";
let uuid_b = r"PCI\VEN_1002&DEV_744C&SUBSYS_0\6&BBBB&0&19";
let adapters = vec![
adapter(0, (3, 0, 0), r"PCI\VEN_1002&DEV_744C&SUBSYS_0\6&CCCC&0&19"),
adapter(1, (8, 0, 0), r"PCI\VEN_1002&DEV_744C&SUBSYS_0\6&DDDD&0&19"),
];
assert_eq!(
plan_attribution(&[uuid_a, uuid_b], Some(&adapters)),
AttributionPlan::Decline
);
}
#[test]
fn the_fallback_never_pairs_across_vendors_or_devices() {
let intel_uuid = r"PCI\VEN_8086&DEV_56A0&SUBSYS_0\3&11583659&0&10";
let adapters = vec![
adapter(0, (4, 0, 0), APU_PNP),
adapter(1, (8, 0, 0), DGPU_PNP),
];
let plan = plan_attribution(&[intel_uuid, DGPU_PNP], Some(&adapters));
assert_eq!(
plan,
AttributionPlan::PerCard(vec![CardMatch {
gpu_index: 1,
adl_indices: vec![1],
}])
);
}
#[test]
fn duplicate_identities_on_either_side_decline_entirely() {
let adapters = vec![adapter(0, (8, 0, 0), DGPU_PNP)];
assert_eq!(
plan_attribution(&[DGPU_PNP, DGPU_PNP], Some(&adapters)),
AttributionPlan::Decline
);
}
#[test]
fn synthetic_uuids_match_nothing() {
let adapters = vec![
adapter(0, (4, 0, 0), APU_PNP),
adapter(1, (8, 0, 0), DGPU_PNP),
];
assert_eq!(
plan_attribution(&["AMD-GPU-0", "AMD-GPU-1"], Some(&adapters)),
AttributionPlan::Decline
);
}
#[test]
fn ffi_rows_round_trip_into_owned_adapters() {
let mut entry = ffi::AdapterInfo {
i_adapter_index: 3,
i_bus_number: 8,
i_device_number: 0,
i_function_number: 0,
i_vendor_id: 1002,
..ffi::AdapterInfo::default()
};
entry.str_adapter_name[..22].copy_from_slice(b"AMD Radeon RX 7900 XTX");
entry.str_pnp_string[..DGPU_PNP.len()].copy_from_slice(DGPU_PNP.as_bytes());
let parsed = parse_adapters(&[entry]);
assert_eq!(
parsed,
vec![AdlAdapter {
index: 3,
bus: 8,
device: 0,
function: 0,
adapter_name: "AMD Radeon RX 7900 XTX".to_string(),
pnp_string: DGPU_PNP.to_string(),
}]
);
entry.str_pnp_string = [0xFF; ffi::ADL_MAX_PATH];
assert_eq!(parse_adapters(&[entry])[0].pnp_string, "");
}
#[test]
fn adapter_count_is_plausible_only_within_the_positive_bound() {
assert!(!plausible_adapter_count(0));
assert!(!plausible_adapter_count(-1));
assert!(plausible_adapter_count(1));
assert!(plausible_adapter_count(MAX_ADAPTER_ROWS));
assert!(!plausible_adapter_count(MAX_ADAPTER_ROWS + 1));
}
#[test]
fn scan_count_clamps_to_the_upper_bound_without_rejecting_it() {
assert_eq!(clamp_scan_count(3), 3);
assert_eq!(clamp_scan_count(MAX_ADAPTER_ROWS), MAX_ADAPTER_ROWS);
assert_eq!(clamp_scan_count(MAX_ADAPTER_ROWS + 1), MAX_ADAPTER_ROWS);
assert_eq!(clamp_scan_count(10_000), MAX_ADAPTER_ROWS);
}
fn written_row(index: i32) -> ffi::AdapterInfo {
let mut entry = ffi::AdapterInfo {
i_adapter_index: index,
i_bus_number: 3,
..ffi::AdapterInfo::default()
};
entry.str_adapter_name[..4].copy_from_slice(b"card");
entry
}
fn garbled_row(index: i32) -> ffi::AdapterInfo {
let mut entry = written_row(index);
entry.str_pnp_string = [0xFF; ffi::ADL_MAX_PATH];
entry
}
#[test]
fn layout_failure_names_garbled_rows_first() {
let rows = vec![written_row(0), garbled_row(1), ffi::AdapterInfo::poisoned()];
let shape = describe_layout_failure(&rows);
assert!(shape.contains("1 of 3"), "{shape}");
assert!(shape.contains("contradict the layout"), "{shape}");
assert!(shape.contains("stride"), "{shape}");
}
#[test]
fn layout_failure_names_a_dead_call_when_row_zero_is_untouched() {
let rows = vec![ffi::AdapterInfo::poisoned(), ffi::AdapterInfo::poisoned()];
let shape = describe_layout_failure(&rows);
assert!(shape.contains("poison"), "{shape}");
assert!(shape.contains("2 of 2"), "{shape}");
assert!(shape.contains("wrote nothing at all"), "{shape}");
}
#[test]
fn layout_failure_names_an_empty_memset_when_nothing_was_populated() {
let rows = vec![ffi::AdapterInfo::default(), ffi::AdapterInfo::default()];
let shape = describe_layout_failure(&rows);
assert!(shape.contains("memset"), "{shape}");
assert!(shape.contains("no adapter was described"), "{shape}");
}
#[test]
fn layout_failure_names_duplicate_indices_as_the_remaining_shape() {
let rows = vec![written_row(0), written_row(0)];
let shape = describe_layout_failure(&rows);
assert!(shape.contains("repeat an iAdapterIndex"), "{shape}");
}
#[test]
fn raw_rows_render_with_quoted_strings_for_the_doctor() {
let mut entry = ffi::AdapterInfo {
i_adapter_index: 2,
i_bus_number: 8,
i_device_number: 0,
i_function_number: 0,
i_vendor_id: 1002,
..ffi::AdapterInfo::default()
};
entry.str_adapter_name[..4].copy_from_slice(b"card");
entry.str_pnp_string[..3].copy_from_slice(b"PCI");
let line = describe_raw_entry(0, &entry);
assert_eq!(
line,
"[0] index=2 bus=8 device=0 function=0 vendor=1002 name=\"card\" pnp=\"PCI\""
);
}
#[test]
fn raw_rows_carry_their_state_so_the_dump_is_decisive() {
assert_eq!(
describe_raw_entry(1, &ffi::AdapterInfo::default()),
"[1] BLANK (driver memset, not populated)"
);
assert_eq!(
describe_raw_entry(2, &ffi::AdapterInfo::poisoned()),
"[2] UNTOUCHED (poison intact, driver never wrote)"
);
let line = describe_raw_entry(3, &garbled_row(7));
assert!(line.starts_with("[3] GARBLED index=7"), "{line}");
assert!(line.contains("pnp="), "{line}");
}
}