diff --git a/FFUDevelopment/BuildFFUUnattend/Set-FFUDataPartitionDriveLetters.ps1 b/FFUDevelopment/BuildFFUUnattend/Set-FFUDataPartitionDriveLetters.ps1
index 80b278c..3a4a774 100644
--- a/FFUDevelopment/BuildFFUUnattend/Set-FFUDataPartitionDriveLetters.ps1
+++ b/FFUDevelopment/BuildFFUUnattend/Set-FFUDataPartitionDriveLetters.ps1
@@ -99,6 +99,61 @@ function Resolve-FFUDataPartition {
}
}
+function Get-FFUDataPartitionAssignmentMode {
+ param(
+ [Parameter(Mandatory = $true)]
+ [object]$ManifestEntry
+ )
+
+ $assignmentMode = 'Configured'
+ if ($ManifestEntry.PSObject.Properties.Name -contains 'AssignmentMode') {
+ $assignmentMode = ([string]$ManifestEntry.AssignmentMode).Trim()
+ }
+ if ($assignmentMode -notin @('Configured', 'Automatic')) {
+ throw "Partition '$($ManifestEntry.Name)' uses unsupported assignment mode '$assignmentMode'."
+ }
+
+ return $assignmentMode
+}
+
+function Get-FFUDeploymentMediaDiskNumbers {
+ $deploymentMediaDiskNumbers = [System.Collections.Generic.HashSet[int]]::new()
+ $deploymentVolumes = @(Get-Volume -ErrorAction SilentlyContinue | Where-Object { ([string]$_.FileSystemLabel).Trim() -ieq 'Deploy' })
+ foreach ($deploymentVolume in $deploymentVolumes) {
+ $driveLetter = ([string]$deploymentVolume.DriveLetter).Trim().TrimEnd(':').ToUpperInvariant()
+ if ($driveLetter -notmatch '^[A-Z]$') {
+ continue
+ }
+
+ $deploymentPartitions = @(Get-Partition -DriveLetter $driveLetter -ErrorAction SilentlyContinue)
+ foreach ($deploymentPartition in $deploymentPartitions) {
+ $deploymentDisk = Get-Disk -Number $deploymentPartition.DiskNumber -ErrorAction SilentlyContinue
+ if ($null -ne $deploymentDisk -and (([string]$deploymentDisk.BusType -ieq 'USB') -or ([string]$deploymentVolume.DriveType -ieq 'Removable'))) {
+ $null = $deploymentMediaDiskNumbers.Add([int]$deploymentPartition.DiskNumber)
+ }
+ }
+ }
+
+ return @($deploymentMediaDiskNumbers | Sort-Object)
+}
+
+function Get-FFUNextAvailableDriveLetter {
+ param(
+ [Parameter(Mandatory = $true)]
+ [AllowEmptyCollection()]
+ [System.Collections.Generic.HashSet[string]]$ReservedDriveLetters
+ )
+
+ foreach ($driveLetterCode in ([int][char]'D')..([int][char]'Z')) {
+ $candidateDriveLetter = [string][char]$driveLetterCode
+ if ($ReservedDriveLetters.Add($candidateDriveLetter)) {
+ return $candidateDriveLetter
+ }
+ }
+
+ throw 'No drive letter from D through Z is available.'
+}
+
function Set-FFUDataPartitionDriveLetters {
param(
[Parameter(Mandatory = $true)]
@@ -121,37 +176,175 @@ function Set-FFUDataPartitionDriveLetters {
Sort-Object -Property PartitionNumber)
$resolvedEntries = [System.Collections.Generic.List[pscustomobject]]::new()
+ $targetPartitionKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($manifestEntry in @($Manifest.Partitions)) {
$requestedDriveLetter = ([string]$manifestEntry.RequestedDriveLetter).Trim().TrimEnd(':').ToUpperInvariant()
if ($requestedDriveLetter -notmatch '^[D-Z]$') {
throw "Partition '$($manifestEntry.Name)' requests invalid drive letter '$requestedDriveLetter'."
}
- $manifestEntry.RequestedDriveLetter = $requestedDriveLetter
- $resolvedEntries.Add((Resolve-FFUDataPartition -ManifestEntry $manifestEntry -DataPartitions $dataPartitions))
+
+ $resolvedPartition = Resolve-FFUDataPartition -ManifestEntry $manifestEntry -DataPartitions $dataPartitions
+ $assignmentMode = Get-FFUDataPartitionAssignmentMode -ManifestEntry $manifestEntry
+ $targetPartitionKey = "$($resolvedPartition.Partition.DiskNumber):$($resolvedPartition.Partition.PartitionNumber)"
+ if (-not $targetPartitionKeys.Add($targetPartitionKey)) {
+ throw "Partition '$($manifestEntry.Name)' resolves to a data partition already targeted by another manifest entry."
+ }
+ $resolvedEntries.Add([pscustomobject]@{
+ ManifestEntry = $resolvedPartition.ManifestEntry
+ Partition = $resolvedPartition.Partition
+ Volume = $resolvedPartition.Volume
+ AssignmentMode = $assignmentMode
+ RequestedDriveLetter = $requestedDriveLetter
+ })
+ }
+
+ $deploymentMediaDiskNumbers = @(Get-FFUDeploymentMediaDiskNumbers)
+ $deploymentMediaDiskNumberSet = [System.Collections.Generic.HashSet[int]]::new()
+ foreach ($deploymentMediaDiskNumber in $deploymentMediaDiskNumbers) {
+ $null = $deploymentMediaDiskNumberSet.Add([int]$deploymentMediaDiskNumber)
+ }
+
+ $reservedDriveLetters = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
+ foreach ($resolvedEntry in $resolvedEntries) {
+ if ($resolvedEntry.AssignmentMode -eq 'Configured') {
+ $null = $reservedDriveLetters.Add([string]$resolvedEntry.RequestedDriveLetter)
+ }
+ }
+
+ foreach ($driveLetterCode in ([int][char]'D')..([int][char]'Z')) {
+ $driveLetter = [string][char]$driveLetterCode
+ $letterOwners = @(Get-Partition -DriveLetter $driveLetter -ErrorAction SilentlyContinue)
+ $hasProtectedOwner = $false
+ foreach ($letterOwner in $letterOwners) {
+ $ownerKey = "$($letterOwner.DiskNumber):$($letterOwner.PartitionNumber)"
+ if (-not $targetPartitionKeys.Contains($ownerKey) -and -not $deploymentMediaDiskNumberSet.Contains([int]$letterOwner.DiskNumber)) {
+ $hasProtectedOwner = $true
+ break
+ }
+ }
+
+ if ($hasProtectedOwner -or ($letterOwners.Count -eq 0 -and $null -ne (Get-PSDrive -Name $driveLetter -PSProvider FileSystem -ErrorAction SilentlyContinue))) {
+ $null = $reservedDriveLetters.Add($driveLetter)
+ }
}
foreach ($resolvedEntry in $resolvedEntries) {
- $requestedDriveLetter = [string]$resolvedEntry.ManifestEntry.RequestedDriveLetter
+ if ($resolvedEntry.AssignmentMode -ne 'Automatic') {
+ continue
+ }
+
+ $resolvedEntry.RequestedDriveLetter = Get-FFUNextAvailableDriveLetter -ReservedDriveLetters $reservedDriveLetters
+ Write-FFUDataPartitionDriveLetterLog "Selected next available drive $($resolvedEntry.RequestedDriveLetter): for '$($resolvedEntry.ManifestEntry.Name)'."
+ }
+
+ $requestedDriveLetters = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
+ foreach ($resolvedEntry in $resolvedEntries) {
+ if (-not $requestedDriveLetters.Add([string]$resolvedEntry.RequestedDriveLetter)) {
+ throw "Drive letter $($resolvedEntry.RequestedDriveLetter): is requested by more than one data partition."
+ }
+ }
+
+ $deploymentMediaDisksToRelocate = [System.Collections.Generic.HashSet[int]]::new()
+ foreach ($resolvedEntry in $resolvedEntries) {
+ $requestedDriveLetter = [string]$resolvedEntry.RequestedDriveLetter
$currentDriveLetter = ([string]$resolvedEntry.Partition.DriveLetter).Trim().TrimEnd(':').ToUpperInvariant()
if ($currentDriveLetter -eq $requestedDriveLetter) {
continue
}
- $requestedVolumes = @(Get-Volume -DriveLetter $requestedDriveLetter -ErrorAction SilentlyContinue)
- if ($requestedVolumes.Count -gt 0) {
- $requestedOwner = @(Get-Partition -DriveLetter $requestedDriveLetter -ErrorAction SilentlyContinue | Select-Object -First 1)
- $ownerDescription = if ($requestedOwner.Count -gt 0) {
- "disk $($requestedOwner[0].DiskNumber), partition $($requestedOwner[0].PartitionNumber)"
+ $requestedOwners = @(Get-Partition -DriveLetter $requestedDriveLetter -ErrorAction SilentlyContinue)
+ foreach ($requestedOwner in $requestedOwners) {
+ $ownerKey = "$($requestedOwner.DiskNumber):$($requestedOwner.PartitionNumber)"
+ if ($targetPartitionKeys.Contains($ownerKey)) {
+ continue
+ }
+ if ($deploymentMediaDiskNumberSet.Contains([int]$requestedOwner.DiskNumber)) {
+ $null = $deploymentMediaDisksToRelocate.Add([int]$requestedOwner.DiskNumber)
+ continue
+ }
+
+ $requestedVolume = @($requestedOwner | Get-Volume -ErrorAction SilentlyContinue | Select-Object -First 1)
+ $ownerDescription = if ($requestedVolume.Count -gt 0 -and -not [string]::IsNullOrWhiteSpace([string]$requestedVolume[0].FileSystemLabel)) {
+ "volume '$($requestedVolume[0].FileSystemLabel)' on disk $($requestedOwner.DiskNumber), partition $($requestedOwner.PartitionNumber)"
}
else {
- "volume '$($requestedVolumes[0].FileSystemLabel)'"
+ "disk $($requestedOwner.DiskNumber), partition $($requestedOwner.PartitionNumber)"
}
throw "Cannot assign drive ${requestedDriveLetter}: to partition '$($resolvedEntry.ManifestEntry.Name)' because the letter is owned by $ownerDescription."
}
+
+ if ($requestedOwners.Count -eq 0) {
+ $existingFileSystemDrive = Get-PSDrive -Name $requestedDriveLetter -PSProvider FileSystem -ErrorAction SilentlyContinue
+ if ($null -ne $existingFileSystemDrive) {
+ throw "Cannot assign drive ${requestedDriveLetter}: to partition '$($resolvedEntry.ManifestEntry.Name)' because it is mapped to '$($existingFileSystemDrive.Root)'."
+ }
+ }
+ }
+
+ $deploymentMediaPartitionsToRelocate = [System.Collections.Generic.List[pscustomobject]]::new()
+ foreach ($deploymentMediaDiskNumber in @($deploymentMediaDisksToRelocate | Sort-Object)) {
+ $deploymentMediaPartitions = @(Get-Partition -DiskNumber $deploymentMediaDiskNumber -ErrorAction Stop | Sort-Object -Property PartitionNumber)
+ foreach ($deploymentMediaPartition in $deploymentMediaPartitions) {
+ $currentDriveLetter = ([string]$deploymentMediaPartition.DriveLetter).Trim().TrimEnd(':').ToUpperInvariant()
+ if ($currentDriveLetter -notmatch '^[D-Z]$') {
+ continue
+ }
+
+ $deploymentMediaVolume = @($deploymentMediaPartition | Get-Volume -ErrorAction SilentlyContinue | Select-Object -First 1)
+ $volumeLabel = if ($deploymentMediaVolume.Count -gt 0) { [string]$deploymentMediaVolume[0].FileSystemLabel } else { '' }
+ $deploymentMediaPartitionsToRelocate.Add([pscustomobject]@{
+ Partition = $deploymentMediaPartition
+ DriveLetter = $currentDriveLetter
+ VolumeLabel = $volumeLabel
+ })
+ }
+ }
+
+ $finalReservedDriveLetters = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
+ foreach ($driveLetterCode in ([int][char]'D')..([int][char]'Z')) {
+ $driveLetter = [string][char]$driveLetterCode
+ $letterOwners = @(Get-Partition -DriveLetter $driveLetter -ErrorAction SilentlyContinue)
+ $hasRemainingOwner = $false
+ foreach ($letterOwner in $letterOwners) {
+ $ownerKey = "$($letterOwner.DiskNumber):$($letterOwner.PartitionNumber)"
+ if (-not $targetPartitionKeys.Contains($ownerKey) -and -not $deploymentMediaDisksToRelocate.Contains([int]$letterOwner.DiskNumber)) {
+ $hasRemainingOwner = $true
+ break
+ }
+ }
+
+ if ($hasRemainingOwner -or ($letterOwners.Count -eq 0 -and $null -ne (Get-PSDrive -Name $driveLetter -PSProvider FileSystem -ErrorAction SilentlyContinue))) {
+ $null = $finalReservedDriveLetters.Add($driveLetter)
+ }
+ }
+ foreach ($resolvedEntry in $resolvedEntries) {
+ $null = $finalReservedDriveLetters.Add([string]$resolvedEntry.RequestedDriveLetter)
+ }
+ $availableMediaDriveLetterCount = 0
+ foreach ($driveLetterCode in ([int][char]'D')..([int][char]'Z')) {
+ if (-not $finalReservedDriveLetters.Contains([string][char]$driveLetterCode)) {
+ $availableMediaDriveLetterCount++
+ }
+ }
+ if ($availableMediaDriveLetterCount -lt $deploymentMediaPartitionsToRelocate.Count) {
+ throw 'There are not enough available drive letters to relocate the FFU deployment media.'
+ }
+
+ # Remove affected access paths first so internal and deployment-media letters can be reordered safely.
+ foreach ($resolvedEntry in $resolvedEntries) {
+ $currentDriveLetter = ([string]$resolvedEntry.Partition.DriveLetter).Trim().TrimEnd(':').ToUpperInvariant()
+ if ($currentDriveLetter -match '^[D-Z]$' -and $currentDriveLetter -ne [string]$resolvedEntry.RequestedDriveLetter) {
+ Write-FFUDataPartitionDriveLetterLog "Removing drive ${currentDriveLetter}: from '$($resolvedEntry.ManifestEntry.Name)' before reassignment."
+ Remove-PartitionAccessPath -DiskNumber $resolvedEntry.Partition.DiskNumber -PartitionNumber $resolvedEntry.Partition.PartitionNumber -AccessPath "${currentDriveLetter}:\" -ErrorAction Stop
+ }
+ }
+ foreach ($deploymentMediaEntry in $deploymentMediaPartitionsToRelocate) {
+ Write-FFUDataPartitionDriveLetterLog "Removing drive $($deploymentMediaEntry.DriveLetter): from FFU deployment media '$($deploymentMediaEntry.VolumeLabel)' before reassignment."
+ Remove-PartitionAccessPath -DiskNumber $deploymentMediaEntry.Partition.DiskNumber -PartitionNumber $deploymentMediaEntry.Partition.PartitionNumber -AccessPath "$($deploymentMediaEntry.DriveLetter):\" -ErrorAction Stop
}
foreach ($resolvedEntry in $resolvedEntries) {
- $requestedDriveLetter = [string]$resolvedEntry.ManifestEntry.RequestedDriveLetter
+ $requestedDriveLetter = [string]$resolvedEntry.RequestedDriveLetter
$currentDriveLetter = ([string]$resolvedEntry.Partition.DriveLetter).Trim().TrimEnd(':').ToUpperInvariant()
if ($currentDriveLetter -ne $requestedDriveLetter) {
$currentDriveLetterText = if ([string]::IsNullOrWhiteSpace($currentDriveLetter)) { 'no drive letter' } else { "drive ${currentDriveLetter}:" }
@@ -165,6 +358,24 @@ function Set-FFUDataPartitionDriveLetters {
}
Write-FFUDataPartitionDriveLetterLog "Verified '$($resolvedEntry.ManifestEntry.Name)' at drive ${requestedDriveLetter}:."
}
+
+ $mediaReservedDriveLetters = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
+ foreach ($driveLetterCode in ([int][char]'D')..([int][char]'Z')) {
+ $driveLetter = [string][char]$driveLetterCode
+ $letterOwners = @(Get-Partition -DriveLetter $driveLetter -ErrorAction SilentlyContinue)
+ if ($letterOwners.Count -gt 0 -or $null -ne (Get-PSDrive -Name $driveLetter -PSProvider FileSystem -ErrorAction SilentlyContinue)) {
+ $null = $mediaReservedDriveLetters.Add($driveLetter)
+ }
+ }
+ foreach ($deploymentMediaEntry in $deploymentMediaPartitionsToRelocate) {
+ $newDriveLetter = Get-FFUNextAvailableDriveLetter -ReservedDriveLetters $mediaReservedDriveLetters
+ Write-FFUDataPartitionDriveLetterLog "Assigning drive ${newDriveLetter}: to FFU deployment media '$($deploymentMediaEntry.VolumeLabel)'."
+ Set-Partition -DiskNumber $deploymentMediaEntry.Partition.DiskNumber -PartitionNumber $deploymentMediaEntry.Partition.PartitionNumber -NewDriveLetter $newDriveLetter -ErrorAction Stop
+ $verifiedMediaPartition = Get-Partition -DiskNumber $deploymentMediaEntry.Partition.DiskNumber -PartitionNumber $deploymentMediaEntry.Partition.PartitionNumber -ErrorAction Stop
+ if (([string]$verifiedMediaPartition.DriveLetter).Trim().TrimEnd(':').ToUpperInvariant() -ne $newDriveLetter) {
+ throw "Drive letter verification failed for FFU deployment media '$($deploymentMediaEntry.VolumeLabel)'."
+ }
+ }
}
function Remove-FFUDataPartitionDriveLetterArtifacts {
diff --git a/FFUDevelopment/BuildFFUVM.ps1 b/FFUDevelopment/BuildFFUVM.ps1
index d239051..b438cbb 100644
--- a/FFUDevelopment/BuildFFUVM.ps1
+++ b/FFUDevelopment/BuildFFUVM.ps1
@@ -115,7 +115,7 @@ Optional fixed size of the Recovery partition in bytes. Leave as 0 to calculate
When set to $false, skips creating the Windows Recovery partition. Default is $true.
.PARAMETER AdditionalDataPartitions
-Optional data partitions to create after the Recovery partition. Each item supports Name, Label, DriveLetter, SizeBytes or SizeGB, FillRemaining, and FileSystem.
+Optional data partitions to create after the Recovery partition. Each item supports Name, Label, DriveLetter, SizeBytes or SizeGB, FillRemaining, FileSystem, and PersistDriveLetter. The configured letter is always used in the build VM. On physical devices, persisted partitions keep that letter and other data partitions receive the next available letter from D through Z.
.PARAMETER DriversFolder
Path to the drivers folder. Default is $FFUDevelopmentPath\Drivers.
@@ -3675,6 +3675,8 @@ function New-FFUDataPartitionDriveLetterManifest {
[string]$WindowsArch,
[Parameter(Mandatory = $true)]
[string]$ManifestPath,
+ [ValidateSet('BuildVm', 'Deployment')]
+ [string]$ManifestPurpose = 'Deployment',
[bool]$Optimize = $false,
[int]$OptimizeFFUPartitionNumber = 0
)
@@ -3684,11 +3686,33 @@ function New-FFUDataPartitionDriveLetterManifest {
$resizablePartitionNumber = Get-FFUOptimizePartitionNumber -Layout $Layout -RequestedPartitionNumber $OptimizeFFUPartitionNumber -AdditionalDataPartitions $AdditionalDataPartitions
}
+ $reservedDeploymentDriveLetters = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
+ if ($ManifestPurpose -eq 'Deployment') {
+ foreach ($dataPartitionConfig in $AdditionalDataPartitions) {
+ if ([bool]$dataPartitionConfig.PersistDriveLetter) {
+ $null = $reservedDeploymentDriveLetters.Add([string]$dataPartitionConfig.DriveLetter)
+ }
+ }
+ }
+
$manifestPartitions = [System.Collections.Generic.List[pscustomobject]]::new()
for ($dataPartitionIndex = 0; $dataPartitionIndex -lt $AdditionalDataPartitions.Count; $dataPartitionIndex++) {
$dataPartitionConfig = $AdditionalDataPartitions[$dataPartitionIndex]
- if (-not [bool]$dataPartitionConfig.PersistDriveLetter) {
- continue
+ $assignmentMode = 'Configured'
+ $requestedDriveLetter = [string]$dataPartitionConfig.DriveLetter
+ if ($ManifestPurpose -eq 'Deployment' -and -not [bool]$dataPartitionConfig.PersistDriveLetter) {
+ $assignmentMode = 'Automatic'
+ $requestedDriveLetter = $null
+ foreach ($driveLetterCode in ([int][char]'D')..([int][char]'Z')) {
+ $candidateDriveLetter = [string][char]$driveLetterCode
+ if ($reservedDeploymentDriveLetters.Add($candidateDriveLetter)) {
+ $requestedDriveLetter = $candidateDriveLetter
+ break
+ }
+ }
+ if ([string]::IsNullOrWhiteSpace($requestedDriveLetter)) {
+ throw "No deployment drive letter is available for data partition '$($dataPartitionConfig.Name)'."
+ }
}
$resolvedDataPartition = $Layout.DataPartitions[$dataPartitionIndex]
@@ -3699,7 +3723,9 @@ function New-FFUDataPartitionDriveLetterManifest {
$manifestPartitions.Add([pscustomobject][ordered]@{
Name = [string]$dataPartitionConfig.Name
- RequestedDriveLetter = [string]$dataPartitionConfig.DriveLetter
+ RequestedDriveLetter = $requestedDriveLetter
+ ConfiguredDriveLetter = [string]$dataPartitionConfig.DriveLetter
+ AssignmentMode = $assignmentMode
DataOrdinal = $dataPartitionIndex + 1
PartitionNumber = [int]$resolvedDataPartition.Partition.PartitionNumber
PartitionGuid = $partitionGuid
@@ -3711,7 +3737,7 @@ function New-FFUDataPartitionDriveLetterManifest {
}
if ($manifestPartitions.Count -eq 0) {
- throw 'Cannot create a data partition drive-letter manifest without opted-in partitions.'
+ throw 'Cannot create a data partition drive-letter manifest without data partitions.'
}
$manifestDirectory = Split-Path -Path $ManifestPath -Parent
@@ -3737,6 +3763,8 @@ function Add-FFUDataPartitionDriveLetterArtifacts {
[object[]]$AdditionalDataPartitions,
[Parameter(Mandatory = $true)]
[string]$WindowsArch,
+ [ValidateSet('BuildVm', 'Deployment')]
+ [string]$ManifestPurpose = 'Deployment',
[bool]$Optimize = $false,
[int]$OptimizeFFUPartitionNumber = 0,
[Parameter(Mandatory = $true)]
@@ -3753,7 +3781,7 @@ function Add-FFUDataPartitionDriveLetterArtifacts {
$manifestPath = Join-Path -Path $runtimeDirectory -ChildPath 'Manifest.json'
New-Item -Path $runtimeDirectory -ItemType Directory -Force | Out-Null
Copy-Item -LiteralPath $runtimeSourcePath -Destination $runtimeScriptPath -Force
- $null = New-FFUDataPartitionDriveLetterManifest -Layout $Layout -AdditionalDataPartitions $AdditionalDataPartitions -WindowsArch $WindowsArch -ManifestPath $manifestPath -Optimize $Optimize -OptimizeFFUPartitionNumber $OptimizeFFUPartitionNumber
+ $null = New-FFUDataPartitionDriveLetterManifest -Layout $Layout -AdditionalDataPartitions $AdditionalDataPartitions -WindowsArch $WindowsArch -ManifestPath $manifestPath -ManifestPurpose $ManifestPurpose -Optimize $Optimize -OptimizeFFUPartitionNumber $OptimizeFFUPartitionNumber
Remove-Item -LiteralPath (Join-Path $runtimeDirectory 'Audit.success'), (Join-Path $runtimeDirectory 'Audit.failure'), (Join-Path $runtimeDirectory 'Specialize.failure') -Force -ErrorAction SilentlyContinue
if (-not (Test-Path -LiteralPath $runtimeScriptPath -PathType Leaf) -or -not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) {
@@ -6718,7 +6746,8 @@ Set-Progress -Percentage 2 -Message "Validating parameters..."
#Set build partition drive letters and validate they are available for use; this is required before any build steps that require drive access to ensure the expected drive letters are reserved and to fail fast if there are conflicts.
try {
$normalizedAdditionalDataPartitions = @(ConvertTo-NormalizedDataPartitions -DataPartitions $AdditionalDataPartitions)
- $persistDataPartitionDriveLetters = @($normalizedAdditionalDataPartitions | Where-Object { $_.PersistDriveLetter }).Count -gt 0
+ $configureDeployedDataPartitionDriveLetters = $normalizedAdditionalDataPartitions.Count -gt 0
+ $enforceBuildVmDataPartitionDriveLetters = $InstallApps -and $configureDeployedDataPartitionDriveLetters
$fillRemainingPartitionCount = if ($OSPartitionSize -le 0) { 1 } else { 0 }
$fillRemainingPartitionCount += @($normalizedAdditionalDataPartitions | Where-Object { $_.FillRemaining }).Count
if ($fillRemainingPartitionCount -gt 1) {
@@ -8771,9 +8800,9 @@ if ($InstallApps) {
$orchestrationBootstrapTargetFolder = "$($osPartitionDriveLetter):\Windows\Setup\Scripts"
New-Item -Path $orchestrationBootstrapTargetFolder -ItemType Directory -Force | Out-Null
Copy-Item -Path $orchestrationBootstrapSourcePath -Destination (Join-Path -Path $orchestrationBootstrapTargetFolder -ChildPath 'Start-FFUOrchestration.ps1') -Force | Out-Null
- if ($persistDataPartitionDriveLetters) {
+ if ($enforceBuildVmDataPartitionDriveLetters) {
$windowsPartitionRoot = "$($osPartitionDriveLetter):\"
- $null = Add-FFUDataPartitionDriveLetterArtifacts -WindowsPartitionRoot $windowsPartitionRoot -Layout $partitionLayout -AdditionalDataPartitions $normalizedAdditionalDataPartitions -WindowsArch $WindowsArch -Optimize $Optimize -OptimizeFFUPartitionNumber $OptimizeFFUPartitionNumber -FFUDevelopmentPath $FFUDevelopmentPath
+ $null = Add-FFUDataPartitionDriveLetterArtifacts -WindowsPartitionRoot $windowsPartitionRoot -Layout $partitionLayout -AdditionalDataPartitions $normalizedAdditionalDataPartitions -WindowsArch $WindowsArch -ManifestPurpose BuildVm -Optimize $Optimize -OptimizeFFUPartitionNumber $OptimizeFFUPartitionNumber -FFUDevelopmentPath $FFUDevelopmentPath
}
if ($WindowsArch -eq 'x64') {
Copy-Item -Path "$FFUDevelopmentPath\BuildFFUUnattend\unattend_x64.xml" -Destination "$($osPartitionDriveLetter):\Windows\Panther\Unattend\Unattend.xml" -Force | Out-Null
@@ -8785,14 +8814,14 @@ if ($InstallApps) {
# Always dismount so downstream VM creation logic has a clean starting point
Dismount-ScratchVhdx -VhdxPath $VHDXPath
}
-elseif ($persistDataPartitionDriveLetters) {
+elseif ($configureDeployedDataPartitionDriveLetters) {
$vhdMeta = Get-VHD -Path $VHDXPath
if ($vhdMeta.Attached) {
WriteLog 'VHDX already mounted; reusing existing mount for data partition drive-letter staging'
$disk = Get-Disk -Number $vhdMeta.DiskNumber
}
else {
- WriteLog 'Mounting VHDX to stage data partition drive-letter persistence'
+ WriteLog 'Mounting VHDX to stage data partition drive-letter assignment'
$disk = Mount-VHD -Path $VHDXPath -Passthru | Get-Disk
}
$partitionLayout = Resolve-VhdxPartitionLayout -Disk $disk -CreateRecoveryPartition $CreateRecoveryPartition -AdditionalDataPartitions $normalizedAdditionalDataPartitions
@@ -8862,7 +8891,7 @@ try {
WriteLog 'Waiting for VM to shutdown'
} while ($FFUVM.State -ne 'Off')
WriteLog 'VM Shutdown'
- if ($persistDataPartitionDriveLetters) {
+ if ($enforceBuildVmDataPartitionDriveLetters) {
$vhdMeta = Get-VHD -Path $VHDXPath
if ($vhdMeta.Attached) {
WriteLog 'VHDX already mounted; reusing existing mount to validate data partition drive-letter audit results'
@@ -8881,6 +8910,7 @@ try {
$runtimeDirectory = Join-Path -Path $windowsPartitionRoot -ChildPath 'Windows\Setup\Scripts\FFUDL'
$runtimeScriptPath = Join-Path -Path $runtimeDirectory -ChildPath 'Apply.ps1'
$manifestPath = Join-Path -Path $runtimeDirectory -ChildPath 'Manifest.json'
+ $null = Add-FFUDataPartitionDriveLetterArtifacts -WindowsPartitionRoot $windowsPartitionRoot -Layout $partitionLayout -AdditionalDataPartitions $normalizedAdditionalDataPartitions -WindowsArch $WindowsArch -Optimize $Optimize -OptimizeFFUPartitionNumber $OptimizeFFUPartitionNumber -FFUDevelopmentPath $FFUDevelopmentPath
if (-not (Test-Path -LiteralPath $runtimeScriptPath -PathType Leaf) -or -not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) {
throw "Data partition drive-letter runtime artifacts are missing from $runtimeDirectory after audit validation."
}
@@ -8892,12 +8922,12 @@ try {
$stagedInjectedUnattendPath = Join-Path -Path $AppsPath -ChildPath 'Unattend\Unattend.xml'
if (Test-Path -LiteralPath $stagedInjectedUnattendPath -PathType Leaf) {
Copy-Item -LiteralPath $stagedInjectedUnattendPath -Destination $deploymentUnattendPath -Force
- WriteLog "Restored staged deployment unattend to $deploymentUnattendPath before adding data partition drive-letter persistence."
+ WriteLog "Restored staged deployment unattend to $deploymentUnattendPath before adding data partition drive-letter assignment."
}
else {
$unattendSource = Get-UnattendSourcePath -UnattendFolder $UnattendFolder -WindowsArch $WindowsArch -UnattendX64FilePath $UnattendX64FilePath -UnattendArm64FilePath $UnattendArm64FilePath
Save-StagedUnattendFile -SourcePath $unattendSource -DestinationPath $deploymentUnattendPath -DeviceNamingMode $DeviceNamingMode -DeviceNameTemplate $normalizedDeviceNameTemplate -WindowsArch $WindowsArch
- WriteLog "Restaged deployment unattend to $deploymentUnattendPath before adding data partition drive-letter persistence."
+ WriteLog "Restaged deployment unattend to $deploymentUnattendPath before adding data partition drive-letter assignment."
}
}
Add-FFUDataPartitionDriveLetterCommandToUnattend -UnattendPath $deploymentUnattendPath -ProcessorArchitecture $WindowsArch
diff --git a/FFUDevelopment/BuildFFUVM_UI.xaml b/FFUDevelopment/BuildFFUVM_UI.xaml
index 83f0c1a..7f562f8 100644
--- a/FFUDevelopment/BuildFFUVM_UI.xaml
+++ b/FFUDevelopment/BuildFFUVM_UI.xaml
@@ -381,7 +381,7 @@
-
+
@@ -408,7 +408,7 @@
-
+
diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1
index 0d3ab5d..992c39a 100644
--- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1
+++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1
@@ -903,7 +903,7 @@ function Initialize-DynamicUIElements {
$persistDriveLetterGridFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.Grid])
$persistDriveLetterGridFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)
$persistDriveLetterFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.CheckBox])
- $persistDriveLetterFactory.SetValue([System.Windows.Controls.Control]::ToolTipProperty, 'Keep this drive letter in the build VM and on devices deployed with FFU Builder.')
+ $persistDriveLetterFactory.SetValue([System.Windows.Controls.Control]::ToolTipProperty, 'Use the configured letter on physical devices. If cleared, deployed Windows assigns the next available letter from D: upward. The build VM always uses the configured letter. FFU deployment USB letters may be shifted.')
$persistDriveLetterFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Center)
$persistDriveLetterFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center)
$persistDriveLetterBinding = New-Object System.Windows.Data.Binding("PersistDriveLetter")
diff --git a/FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1 b/FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1
index 3fbf8ec..834cb71 100644
--- a/FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1
+++ b/FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1
@@ -165,7 +165,7 @@ function Get-FFUDataPartitionDriveLetterDeploymentContext {
throw "Unsupported data partition drive-letter processor architecture '$processorArchitecture'."
}
- WriteLog "Found data partition drive-letter persistence manifest with $(@($manifest.Partitions).Count) partition(s)."
+ WriteLog "Found data partition drive-letter assignment manifest with $(@($manifest.Partitions).Count) partition(s)."
return [pscustomobject]@{
RuntimeDirectory = $runtimeDirectory
RuntimeScriptPath = $runtimeScriptPath
@@ -1943,7 +1943,7 @@ if ($null -ne $dataPartitionDriveLetterDeploymentContext) {
$deploymentUnattendPath = 'W:\Windows\Panther\Unattend.xml'
try {
if ($Unattend) {
- WriteLog "Merging data partition drive-letter persistence into custom unattend at $deploymentUnattendPath."
+ WriteLog "Merging data partition drive-letter assignment into custom unattend at $deploymentUnattendPath."
Add-FFUDataPartitionDriveLetterCommandToAppliedUnattend -UnattendPath $deploymentUnattendPath -ProcessorArchitecture $dataPartitionDriveLetterDeploymentContext.ProcessorArchitecture
}
else {
diff --git a/docs/build.md b/docs/build.md
index 241824f..97d563a 100644
--- a/docs/build.md
+++ b/docs/build.md
@@ -266,19 +266,19 @@ VHDX caching trades disk space for speed. The `VHDXCache` folder can grow over t
>
> To force a full rebuild, delete the contents of `$FFUDevelopmentPath\VHDXCache` (or disable **Allow VHDX Caching**) and run the build again.
-### Persisted Data Drive Letters
+### Data Drive Letter Assignment
-Data partitions can opt in to **Persist Drive Letter** in **Disk Layout**. The option is off by default, and data letters are limited to `D:` through `Z:`.
+Each data partition's configured letter is used in the build VM whenever FFU Builder installs applications in a VM. This happens whether or not **Persist Drive Letter** is selected. The option is off by default, controls whether the configured letter is also used on deployed physical devices, and data letters are limited to `D:` through `Z:`.
-For builds that install applications in a VM, FFU Builder applies and verifies opted-in letters in audit mode before it searches for the Apps ISO or runs application scripts. The host requires an audit success marker before it captures the VHDX. For builds without a VM, the same deployment files are added directly to the working VHDX before capture.
+FFU Builder applies and verifies every configured data letter in audit mode before it searches for the Apps ISO or runs application scripts. The host requires an audit success marker before it captures the VHDX. Before capture, FFU Builder replaces the build-VM manifest with a deployment manifest containing every data partition. Entries with **Persist Drive Letter** selected retain the configured letter; other entries use automatic assignment. Builds without a VM stage the same deployment files directly in the working VHDX whenever it contains data partitions.
On a deployed device, FFU Builder inserts the assignment command first in `Microsoft-Windows-Deployment\RunSynchronous` for the Windows `specialize` pass. Microsoft documents that these commands run in order and in system context during `specialize`; see [RunSynchronous](https://learn.microsoft.com/windows-hardware/customize/desktop/unattend/microsoft-windows-deployment-runsynchronous). The command uses `WillReboot=OnRequest`, returns `0` only after assignment and cleanup succeed, and returns `3` on failure. Microsoft documents that with `OnRequest`, return codes other than `0`, `1`, or `2` terminate installation; see [WillReboot](https://learn.microsoft.com/windows-hardware/customize/desktop/unattend/microsoft-windows-deployment-runsynchronous-runsynchronouscommand-willreboot).
-An optimized FFU can resize the Windows partition or the partition selected by `-OptimizeFFUPartitionNumber` when it is applied to a differently sized drive. If an opted-in data partition is the selected resize target, drive-letter enforcement accepts its deployed size while still requiring its GPT identity, partition number, volume label, and file system to match the captured partition. Other data partitions must retain their captured sizes. See [Optimize an FFU](https://learn.microsoft.com/windows-hardware/manufacture/desktop/deploy-windows-using-full-flash-update--ffu?view=windows-11#optimize-an-ffu).
+An optimized FFU can resize the Windows partition or the partition selected by `-OptimizeFFUPartitionNumber` when it is applied to a differently sized drive. If a data partition is the selected resize target, drive-letter enforcement accepts its deployed size while still requiring its GPT identity, partition number, volume label, and file system to match the captured partition. Other data partitions must retain their captured sizes. See [Optimize an FFU](https://learn.microsoft.com/windows-hardware/manufacture/desktop/deploy-windows-using-full-flash-update--ffu?view=windows-11#optimize-an-ffu).
-All requested-letter conflicts are fatal. FFU Builder does not move an Apps ISO, deployment USB, existing fixed volume, or another partition, and it does not select a substitute letter. A successful deployed run removes the dedicated runtime directory, script, manifest, and markers. It leaves only `C:\Windows\Temp\FFUDataPartitionDriveLetters.log`. A failed run retains the runtime inputs and failure marker for diagnosis.
+On a deployed device, partitions with **Persist Drive Letter** selected require their configured letters. Unchecked partitions receive the lowest available letters from `D:` upward in data-partition order, after configured letters and unrelated occupied letters are reserved. If a needed letter belongs to recognized FFU deployment media, FFU Builder shifts the assigned letters for that USB or removable disk after the internal data letters. It does not move unrelated volumes or file-system mappings; a configured-letter conflict with one of those owners is fatal. A successful deployed run removes the dedicated runtime directory, script, manifest, and markers. It leaves only `C:\Windows\Temp\FFUDataPartitionDriveLetters.log`. A failed run retains the runtime inputs and failure marker for diagnosis.
-When FFU Builder deployment media applies the image, `ApplyFFU.ps1` treats PE letters as temporary. If a custom deployment unattend is copied from USB, the script merges the persistence command into that final Panther answer file and preserves its other settings. Without a custom unattend, it verifies that the command embedded in the FFU is still present. Builds with no opted-in data partitions add no persistence directory, manifest, markers, or unattend command.
+When FFU Builder deployment media applies the image, `ApplyFFU.ps1` treats PE letters as temporary. If a custom deployment unattend is copied from USB, the script merges the assignment command into that final Panther answer file and preserves its other settings. Without a custom unattend, it verifies that the command embedded in the FFU is still present. Captured images with no data partitions contain no assignment directory, manifest, markers, or unattend command.
### Create Deployment Media
diff --git a/docs/hyperv_settings.md b/docs/hyperv_settings.md
index fd3d06c..e1ecc00 100644
--- a/docs/hyperv_settings.md
+++ b/docs/hyperv_settings.md
@@ -55,9 +55,11 @@ Use the Recovery size only when you need a fixed Recovery partition size. Leave
The Recovery partition can be removed by selecting its row checkbox and using **Remove Selected**. Use **Restore Recovery** to add it back before saving or building. Removing Recovery saves `CreateRecoveryPartition` as `false` in the generated config.
-Each data partition has a name, a drive letter from `D:` through `Z:`, and either a size in GB or **Fill Remaining**. Only one Windows or data partition can use **Fill Remaining**. Data partitions can be reordered with the arrow buttons. **Clear** removes only data partitions, not the base partition rows.
+Each data partition has a name, a drive letter from `D:` through `Z:`, and either a size in GB or **Fill Remaining**. When FFU Builder installs applications in a build VM, the partition uses this configured letter before any application scripts run. Only one Windows or data partition can use **Fill Remaining**. Data partitions can be reordered with the arrow buttons. **Clear** removes only data partitions, not the base partition rows.
-**Persist Drive Letter** is off by default. When selected for a data partition, FFU Builder requires that partition to use its configured letter before build-VM application scripts run and when a deployed device enters the Windows `specialize` pass. If another volume already owns the requested letter, the build or deployment stops instead of moving that volume or selecting another letter.
+**Persist Drive Letter** is off by default and controls whether the configured letter is also required on a physical device. The build VM uses the configured letter whether or not this option is selected. When selected, FFU Builder requires the same letter when a deployed device enters the Windows `specialize` pass. When cleared, the partition receives the lowest available letter from `D:` upward in data-partition order.
+
+During `specialize`, FFU Builder reserves configured persisted letters and letters owned by unrelated volumes before assigning unchecked partitions. If recognized FFU deployment media occupies a needed letter, all lettered partitions on that USB or removable disk are shifted to the next available letters after the internal data partitions. FFU Builder does not move unrelated volumes or file-system mappings; a configured-letter conflict with one of those owners stops deployment.
Windows PE drive letters are temporary and can change when hardware is detected, so assigning a letter only in PE does not provide the installed-Windows guarantee. See [WinPE: Identify drive letters with a script](https://learn.microsoft.com/windows-hardware/manufacture/desktop/winpe-identify-drive-letters?view=windows-11). Successful first-boot enforcement removes its temporary script and manifest and leaves the diagnostic log at `C:\Windows\Temp\FFUDataPartitionDriveLetters.log`.
diff --git a/docs/parameters_reference.md b/docs/parameters_reference.md
index ca9a201..0ec8364 100644
--- a/docs/parameters_reference.md
+++ b/docs/parameters_reference.md
@@ -19,7 +19,7 @@ This table lists all top-level parameters in BuildFFUVM.ps1.
| Parameter | Type | UI Control | Description |
| --- | --- | --- | --- |
| -AdditionalFFUFiles | string[] | Copy Additional FFU Files + Additional FFU Files list | Array of full file paths to existing FFU files that should also be copied to the deployment USB when -CopyAdditionalFFUFiles is set to $true. |
-| -AdditionalDataPartitions | object[] | Disk Layout | Creates optional data partitions after the base Windows layout. Recovery normally remains before data partitions unless -CreateRecoveryPartition is $false. Each item supports Name, Label, DriveLetter, SizeBytes or SizeGB, FillRemaining, FileSystem, and PersistDriveLetter. DriveLetter must be D through Z, only one item can use FillRemaining, and PersistDriveLetter defaults to $false. |
+| -AdditionalDataPartitions | object[] | Disk Layout | Creates optional data partitions after the base Windows layout. Recovery normally remains before data partitions unless -CreateRecoveryPartition is $false. Each item supports Name, Label, DriveLetter, SizeBytes or SizeGB, FillRemaining, FileSystem, and PersistDriveLetter. DriveLetter must be D through Z and is always used in an application build VM. Only one item can use FillRemaining. PersistDriveLetter defaults to $false; unchecked partitions receive the next available physical-device letter from D upward. |
| -AllowExternalHardDiskMedia | bool | Allow External Hard Disk Media | When set to $true, will allow the use of media identified as External Hard Disk media via WMI class Win32_DiskDrive. Default is not defined. |
| -AllowVHDXCaching | bool | Allow VHDX Caching | When set to $true, will cache the VHDX file to the $FFUDevelopmentPath\VHDXCache folder and create a config json file that will keep track of the Windows build information, the updates installed, and the logical sector byte size information. Default is $false. |
| -AppListPath | string | AppList.json Path | Path to a JSON file containing a list of applications to install using WinGet. Default is $FFUDevelopmentPath\Apps\AppList.json. |
@@ -120,12 +120,12 @@ Each item supports the following fields:
| --- | --- | --- |
| `Name` | Yes | Partition name used in configuration and logs. |
| `Label` | No | Volume label. Defaults to `Name`. |
-| `DriveLetter` | Yes | Build letter from `D` through `Z`, without a colon. It must be unique across the complete build layout. |
+| `DriveLetter` | Yes | Configured letter from `D` through `Z`, without a colon. It must be unique across the complete build layout and is always used in an application build VM. It is required on a physical device only when `PersistDriveLetter` is `$true`. |
| `SizeBytes` or `SizeGB` | Conditional | Fixed partition size. Omit only when `FillRemaining` is `$true`. |
| `FillRemaining` | No | Uses the remaining VHDX space. Default is `$false`; only one data partition can enable it. |
| `FileSystem` | No | `NTFS` or `ReFS`. Default is `NTFS`. |
-| `PersistDriveLetter` | No | When `$true`, requires the selected data letter in the build VM and deployed Windows. Default is `$false`. Any occupied-letter conflict stops the build or deployment. |
+| `PersistDriveLetter` | No | When `$true`, requires the configured data letter in deployed Windows. When `$false`, assigns the next available letter from `D` upward. Default is `$false`. The build VM uses the configured letter regardless of this value. Recognized FFU deployment USB letters may be shifted; unrelated occupied letters remain protected. |
-`PersistDriveLetter` does not change VHDX cache compatibility. It controls per-build runtime artifacts that are added only to the working VHDX after the reusable base has been cached or copied.
+`PersistDriveLetter` does not change VHDX cache compatibility. For application builds, temporary runtime artifacts enforce every configured data letter in the build VM after the reusable base has been cached or copied. Before capture, FFU Builder replaces the build-VM manifest with a physical-device manifest for every data partition. Builds without applications stage the physical-device manifest directly when data partitions exist.
{% include page_nav.html %}