diff --git a/ClumsyPilot/ClumsyPilot.csproj b/ClumsyPilot/ClumsyPilot.csproj
index 0592399..fbd1869 100644
--- a/ClumsyPilot/ClumsyPilot.csproj
+++ b/ClumsyPilot/ClumsyPilot.csproj
@@ -38,4 +38,15 @@
+
+
+
+
+
+
+
diff --git a/ClumsyPilot/scripts/Publish-ClumsyPilotPlugin.ps1 b/ClumsyPilot/scripts/Publish-ClumsyPilotPlugin.ps1
new file mode 100644
index 0000000..c124f93
--- /dev/null
+++ b/ClumsyPilot/scripts/Publish-ClumsyPilotPlugin.ps1
@@ -0,0 +1,122 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)]
+ [string]$ManagedDll,
+
+ [Parameter(Mandatory = $true)]
+ [string]$OutputDirectory
+)
+
+$ErrorActionPreference = 'Stop'
+
+function Get-PeMachine {
+ param([Parameter(Mandatory = $true)][string]$Path)
+
+ [byte[]]$bytes = [System.IO.File]::ReadAllBytes($Path)
+ if ($bytes.Length -le 0x40 -or $bytes[0] -ne 0x4d -or $bytes[1] -ne 0x5a) {
+ throw "Native runtime is not a PE file: $Path"
+ }
+
+ [int]$peOffset = [System.BitConverter]::ToInt32($bytes, 0x3c)
+ if ($peOffset -lt 0 -or $peOffset + 6 -gt $bytes.Length -or $bytes[$peOffset] -ne 0x50 -or $bytes[$peOffset + 1] -ne 0x45) {
+ throw "Native runtime has an invalid PE header: $Path"
+ }
+
+ return [System.BitConverter]::ToUInt16($bytes, $peOffset + 4)
+}
+
+function Test-SamePath {
+ param([string]$Left, [string]$Right)
+
+ return [string]::Equals($Left.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar),
+ $Right.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar),
+ [System.StringComparison]::OrdinalIgnoreCase)
+}
+
+$managedDllPath = [System.IO.Path]::GetFullPath($ManagedDll)
+$outputDirectoryPath = [System.IO.Path]::GetFullPath($OutputDirectory)
+$driveRoot = [System.IO.Path]::GetPathRoot($outputDirectoryPath)
+$workspaceRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
+
+if (Test-SamePath $outputDirectoryPath $driveRoot) {
+ throw 'Refusing to publish into a drive root.'
+}
+if (Test-SamePath $outputDirectoryPath $workspaceRoot) {
+ throw 'Refusing to publish into the workspace root.'
+}
+if (-not [System.IO.File]::Exists($managedDllPath)) {
+ throw "Managed DLL does not exist: $managedDllPath"
+}
+if (-not [System.Environment]::Is64BitProcess) {
+ throw 'Refusing to publish from a non-x64 PowerShell host.'
+}
+
+$clumsyPilotRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
+$osqpRoot = Join-Path $clumsyPilotRoot 'ThirdParty\OSQP'
+$nativeDll = Join-Path $osqpRoot 'win-x64\osqp.dll'
+$hashManifest = Join-Path $osqpRoot 'SHA256SUMS'
+$license = Join-Path $osqpRoot 'LICENSE'
+$notice = Join-Path $osqpRoot 'NOTICE'
+$version = Join-Path $osqpRoot 'VERSION'
+$requiredFiles = @($nativeDll, $hashManifest, $license, $notice, $version)
+foreach ($requiredFile in $requiredFiles) {
+ if (-not [System.IO.File]::Exists($requiredFile)) {
+ throw "Required OSQP package file does not exist: $requiredFile"
+ }
+}
+
+if ((Get-PeMachine $nativeDll) -ne 0x8664) {
+ throw 'Refusing to publish a non-x64 OSQP runtime.'
+}
+
+$manifestTokens = @((Get-Content -Raw -LiteralPath $hashManifest) -split '\s+' | Where-Object { $_.Length -gt 0 })
+if ($manifestTokens.Length -lt 2 -or $manifestTokens[1] -ne 'win-x64/osqp.dll') {
+ throw 'SHA256SUMS does not describe the pinned win-x64 OSQP runtime.'
+}
+$expectedHash = $manifestTokens[0].ToLowerInvariant()
+$actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $nativeDll).Hash.ToLowerInvariant()
+if ($actualHash -ne $expectedHash) {
+ throw 'OSQP runtime SHA-256 does not match SHA256SUMS.'
+}
+
+[System.IO.Directory]::CreateDirectory($outputDirectoryPath) | Out-Null
+$pluginsDirectory = Join-Path $outputDirectoryPath 'plugins'
+$nonce = [System.Guid]::NewGuid().ToString('N')
+$stagingDirectory = Join-Path $outputDirectoryPath ('.plugins-staging-' + $nonce)
+$backupDirectory = Join-Path $outputDirectoryPath ('.plugins-backup-' + $nonce)
+$movedExistingPlugins = $false
+
+try {
+ [System.IO.Directory]::CreateDirectory((Join-Path $stagingDirectory 'licenses')) | Out-Null
+ [System.IO.File]::Copy($managedDllPath, (Join-Path $stagingDirectory 'ClumsyPilot.dll'), $false)
+ [System.IO.File]::Copy($nativeDll, (Join-Path $stagingDirectory 'osqp.dll'), $false)
+ [System.IO.File]::Copy($license, (Join-Path $stagingDirectory 'licenses\OSQP-LICENSE.txt'), $false)
+ [System.IO.File]::Copy($notice, (Join-Path $stagingDirectory 'licenses\OSQP-NOTICE.txt'), $false)
+ [System.IO.File]::Copy($version, (Join-Path $stagingDirectory 'licenses\OSQP-VERSION.txt'), $false)
+
+ if ([System.IO.Directory]::Exists($pluginsDirectory)) {
+ [System.IO.Directory]::Move($pluginsDirectory, $backupDirectory)
+ $movedExistingPlugins = $true
+ }
+
+ [System.IO.Directory]::Move($stagingDirectory, $pluginsDirectory)
+
+ if ($movedExistingPlugins -and [System.IO.Directory]::Exists($backupDirectory)) {
+ [System.IO.Directory]::Delete($backupDirectory, $true)
+ }
+}
+catch {
+ if (-not [System.IO.Directory]::Exists($pluginsDirectory) -and $movedExistingPlugins -and
+ [System.IO.Directory]::Exists($backupDirectory)) {
+ [System.IO.Directory]::Move($backupDirectory, $pluginsDirectory)
+ }
+ throw
+}
+finally {
+ if ([System.IO.Directory]::Exists($stagingDirectory)) {
+ [System.IO.Directory]::Delete($stagingDirectory, $true)
+ }
+ if ([System.IO.Directory]::Exists($backupDirectory) -and [System.IO.Directory]::Exists($pluginsDirectory)) {
+ [System.IO.Directory]::Delete($backupDirectory, $true)
+ }
+}
diff --git a/ClumsyPilot/tests/EMPlannerVerificationHost/PluginPackagingChecks.cs b/ClumsyPilot/tests/EMPlannerVerificationHost/PluginPackagingChecks.cs
new file mode 100644
index 0000000..acca903
--- /dev/null
+++ b/ClumsyPilot/tests/EMPlannerVerificationHost/PluginPackagingChecks.cs
@@ -0,0 +1,146 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Reflection;
+using System.Security.Cryptography;
+
+namespace EMPlannerVerificationHost;
+
+internal static class PluginPackagingChecks
+{
+ public static void Run()
+ {
+ string repositoryRoot = Directory.GetCurrentDirectory();
+ string scriptPath = Path.Combine(repositoryRoot, "ClumsyPilot", "scripts", "Publish-ClumsyPilotPlugin.ps1");
+ Verification.True(File.Exists(scriptPath), "plugin publish script exists");
+
+ string managedDll = Path.Combine(AppContext.BaseDirectory, "ClumsyPilot.dll");
+ Verification.True(File.Exists(managedDll), "verification host has managed ClumsyPilot.dll");
+ VerifyBuildOutputMetadata();
+
+ string packageRoot = Path.Combine(Path.GetTempPath(), "em-planner-plugin-package-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(packageRoot);
+ try
+ {
+ Publish(scriptPath, managedDll, packageRoot);
+ VerifyPackage(repositoryRoot, packageRoot);
+
+ Publish(scriptPath, managedDll, packageRoot);
+ VerifyPackage(repositoryRoot, packageRoot);
+ AssertNoStaleSiblingDirectories(packageRoot);
+ }
+ finally
+ {
+ if (Directory.Exists(packageRoot))
+ Directory.Delete(packageRoot, true);
+ }
+ }
+
+ private static void Publish(string scriptPath, string managedDll, string packageRoot)
+ {
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = "powershell.exe",
+ WorkingDirectory = Directory.GetCurrentDirectory(),
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ };
+ startInfo.ArgumentList.Add("-NoProfile");
+ startInfo.ArgumentList.Add("-ExecutionPolicy");
+ startInfo.ArgumentList.Add("Bypass");
+ startInfo.ArgumentList.Add("-File");
+ startInfo.ArgumentList.Add(scriptPath);
+ startInfo.ArgumentList.Add("-ManagedDll");
+ startInfo.ArgumentList.Add(managedDll);
+ startInfo.ArgumentList.Add("-OutputDirectory");
+ startInfo.ArgumentList.Add(packageRoot);
+
+ using (var process = new Process { StartInfo = startInfo })
+ {
+ process.Start();
+ string standardOutput = process.StandardOutput.ReadToEnd();
+ string standardError = process.StandardError.ReadToEnd();
+ process.WaitForExit();
+ if (process.ExitCode != 0)
+ {
+ throw new InvalidOperationException("plugin publish exited " + process.ExitCode + ": " +
+ standardOutput + standardError);
+ }
+ }
+ }
+
+ private static void VerifyPackage(string repositoryRoot, string packageRoot)
+ {
+ string pluginsDirectory = Path.Combine(packageRoot, "plugins");
+ Verification.True(Directory.Exists(pluginsDirectory), "plugins directory exists");
+
+ var relativeFiles = new List();
+ string[] files = Directory.GetFiles(pluginsDirectory, "*", SearchOption.AllDirectories);
+ for (int index = 0; index < files.Length; index++)
+ relativeFiles.Add(Path.GetRelativePath(packageRoot, files[index]).Replace('\\', '/'));
+ relativeFiles.Sort(StringComparer.Ordinal);
+
+ Verification.Equal("plugins/ClumsyPilot.dll|plugins/licenses/OSQP-LICENSE.txt|plugins/licenses/OSQP-NOTICE.txt|" +
+ "plugins/licenses/OSQP-VERSION.txt|plugins/osqp.dll", string.Join("|", relativeFiles),
+ "plugin tree contains exactly the managed DLL, native DLL, and OSQP license files");
+
+ string managedPlugin = Path.Combine(pluginsDirectory, "ClumsyPilot.dll");
+ AssemblyName managedAssembly = AssemblyName.GetAssemblyName(managedPlugin);
+ Verification.True(!string.IsNullOrWhiteSpace(managedAssembly.Name), "packaged ClumsyPilot.dll is managed");
+
+ string nativePlugin = Path.Combine(pluginsDirectory, "osqp.dll");
+ Verification.Equal((ushort)0x8664, ReadPeMachine(nativePlugin), "packaged OSQP binary is x64");
+ Verification.Equal(ReadExpectedOsqpHash(repositoryRoot), GetSha256(nativePlugin),
+ "packaged OSQP hash matches SHA256SUMS");
+ }
+
+ private static void AssertNoStaleSiblingDirectories(string packageRoot)
+ {
+ string parentDirectory = Path.GetDirectoryName(packageRoot) ??
+ throw new InvalidOperationException("temporary package root has no parent directory");
+ string prefix = Path.GetFileName(packageRoot);
+ string[] matchingDirectories = Directory.GetDirectories(parentDirectory, prefix + "*");
+ Verification.Equal(1, matchingDirectories.Length, "publish leaves no stale sibling staging directory");
+ Verification.Equal(packageRoot, matchingDirectories[0], "publish keeps only the requested temporary test root");
+ }
+
+ private static ushort ReadPeMachine(string path)
+ {
+ byte[] bytes = File.ReadAllBytes(path);
+ Verification.True(bytes.Length > 0x40 && bytes[0] == 'M' && bytes[1] == 'Z', "OSQP has DOS header");
+ int peOffset = BitConverter.ToInt32(bytes, 0x3c);
+ Verification.True(peOffset >= 0 && peOffset + 6 <= bytes.Length && bytes[peOffset] == 'P' &&
+ bytes[peOffset + 1] == 'E', "OSQP has PE header");
+ return BitConverter.ToUInt16(bytes, peOffset + 4);
+ }
+
+ private static string ReadExpectedOsqpHash(string repositoryRoot)
+ {
+ string manifestPath = Path.Combine(repositoryRoot, "ClumsyPilot", "ThirdParty", "OSQP", "SHA256SUMS");
+ string[] tokens = File.ReadAllText(manifestPath).Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
+ Verification.True(tokens.Length >= 2 && string.Equals(tokens[1], "win-x64/osqp.dll", StringComparison.Ordinal),
+ "OSQP hash manifest describes the pinned x64 runtime");
+ return tokens[0].ToLowerInvariant();
+ }
+
+ private static string GetSha256(string path)
+ {
+ return Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))).ToLowerInvariant();
+ }
+
+ private static void VerifyBuildOutputMetadata()
+ {
+ string outputDirectory = AppContext.BaseDirectory;
+ Verification.True(File.Exists(Path.Combine(outputDirectory, "osqp.dll")),
+ "build output contains the pinned OSQP runtime");
+ Verification.True(File.Exists(Path.Combine(outputDirectory, "licenses", "OSQP-LICENSE.txt")),
+ "build output contains the OSQP license");
+ Verification.True(File.Exists(Path.Combine(outputDirectory, "licenses", "OSQP-NOTICE.txt")),
+ "build output contains the OSQP notice");
+ Verification.True(File.Exists(Path.Combine(outputDirectory, "licenses", "OSQP-VERSION.txt")),
+ "build output contains the OSQP version");
+ }
+}
diff --git a/ClumsyPilot/tests/EMPlannerVerificationHost/Program.cs b/ClumsyPilot/tests/EMPlannerVerificationHost/Program.cs
index 9fc64cb..dd2fb82 100644
--- a/ClumsyPilot/tests/EMPlannerVerificationHost/Program.cs
+++ b/ClumsyPilot/tests/EMPlannerVerificationHost/Program.cs
@@ -13,7 +13,7 @@ internal static class Program
args[0] != "longitudinal-model" && args[0] != "longitudinal-integration" &&
args[0] != "longitudinal-real-osqp-probe" && args[0] != "trajectory" &&
args[0] != "em-planning-service" && args[0] != "em-core-all" && args[0] != "coordinator" &&
- args[0] != "executor"))
+ args[0] != "executor" && args[0] != "plugin-package"))
{
Console.Error.WriteLine("Usage: EMPlannerVerificationHost foundation|segmentation|frenet|corridor|optimization|osqp|osqp-loader|all-foundation|lateral-model|lateral-integration|lateral-real-osqp|lateral-all|longitudinal-model|longitudinal-integration");
return 2;
@@ -119,6 +119,11 @@ internal static class Program
ExecutorChecks.Run();
Console.WriteLine("PASS executor");
}
+ if (args[0] == "plugin-package")
+ {
+ PluginPackagingChecks.Run();
+ Console.WriteLine("PASS plugin-package");
+ }
return 0;
}
catch (Exception exception)