diff --git a/.gitignore b/.gitignore index 1f51f99..d195f40 100644 --- a/.gitignore +++ b/.gitignore @@ -428,8 +428,13 @@ FodyWeavers.xsd *.msm *.msp -# vcpkg installed libraries +# vcpkg installed libraries (MSBuild: vcpkg_installed/, CMake: vcpkg_installed/cmake-) vcpkg_installed/ +# CMake build trees, CLion build directories and machine-specific presets +out/ +cmake-build-*/ +CMakeUserPresets.json + # C++ language server compilation database .mscppls/ diff --git a/build/scripts/Compare-CompileCommands.ps1 b/build/scripts/Compare-CompileCommands.ps1 new file mode 100644 index 0000000..8fdc61f --- /dev/null +++ b/build/scripts/Compare-CompileCommands.ps1 @@ -0,0 +1,514 @@ +<# +.SYNOPSIS + Measures compile, link and binary parity between the MSBuild and CMake builds. + +.DESCRIPTION + Collects the effective compiler and linker command lines of a build, + normalises them (paths relative to the repository, build-directory paths + replaced by placeholders, option order sorted) and writes a JSON report. + + The MSBuild side is read from a detailed MSBuild file log. Without + -MSBuildLog, the script builds MapWinGIS6.slnx itself and records that log. + The CMake side is optional: pass -CMakeBuildDir to read its + compile_commands.json and, with -CMakeBuildLog, the link lines of a verbose + build (`cmake --build -- -v`). When both sides are present, each + translation unit and each linked binary is diffed and every option that only + one build uses is reported. + + The report also contains the warning count of each build and, with + -Binaries, a dumpbin summary (dependents, exports, subsystem, DLL + characteristics) of each binary in both output folders. + + MSBuild is located in this order: -MSBuild, the MWG_MSBUILD environment + variable, msbuild on PATH, then vswhere. + +.EXAMPLE + pwsh -File build\scripts\Compare-CompileCommands.ps1 -Configuration Debug -Platform x64 + + Builds the solution with MSBuild and writes the baseline report. + +.EXAMPLE + pwsh -File build\scripts\Compare-CompileCommands.ps1 -MSBuildLog out\parity\msbuild-Debug-x64.log ` + -CMakeBuildDir out\b\x64d -CMakeBuildLog out\parity\cmake-x64d.log -Binaries MapWinGIS.Core.dll + + Compares an existing MSBuild log with a CMake Ninja build. +#> +[CmdletBinding()] +param( + [ValidateSet('Debug', 'Release')] + [string] $Configuration = 'Debug', + + [ValidateSet('x64', 'Win32')] + [string] $Platform = 'x64', + + [string] $MSBuild, + + [string] $MSBuildLog, + + [string] $MSBuildBinDirectory, + + [string] $CMakeBuildDir, + + [string] $CMakeBuildLog, + + [string] $CMakeBinDirectory, + + [string[]] $Binaries = @(), + + [string] $OutputPath, + + [switch] $FailOnLostFlags +) + +$ErrorActionPreference = 'Stop' + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$parityDir = Join-Path $repoRoot 'out\parity' + +# Options that only control logging, parallelism or object/PDB file names, so +# they say nothing about the generated code. +$ignoredOptions = @( + '/c', '/nologo', '/MP', '/FC', '/errorReport:queue', '/ERRORREPORT:QUEUE', '/NOLOGO', + '/diagnostics:column', '/diagnostics:caret', '/showIncludes', '/FS' +) +$ignoredOptionPrefixes = @('/Fo', '/Fd', '/Fe', '/Fp', '/ILK:', '/PDB:', '/IMPLIB:', '/OUT:', '/MP') + +# MSBuild project names that differ from the CMake target names. +$targetNames = @{ 'Translate' = 'MapWinGIS.Samples.Translate' } + +function Find-MSBuild { + if ($MSBuild) { return $MSBuild } + if ($env:MWG_MSBUILD) { return $env:MWG_MSBUILD } + + $onPath = Get-Command msbuild -ErrorAction SilentlyContinue + if ($onPath) { return $onPath.Source } + + $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' + if (Test-Path -LiteralPath $vswhere) { + $found = & $vswhere -latest -requires Microsoft.Component.MSBuild -find 'MSBuild\**\Bin\MSBuild.exe' | + Select-Object -First 1 + if ($found) { return $found } + } + + throw 'MSBuild was not found. Set MWG_MSBUILD, pass -MSBuild, or run from a Developer PowerShell.' +} + +function Find-Dumpbin { + $onPath = Get-Command dumpbin -ErrorAction SilentlyContinue + if ($onPath) { return $onPath.Source } + + $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' + if (Test-Path -LiteralPath $vswhere) { + $found = & $vswhere -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 ` + -find 'VC\Tools\MSVC\**\bin\Hostx64\x64\dumpbin.exe' | Select-Object -First 1 + if ($found) { return $found } + } + return $null +} + +# Splits a command line the way the MSVC tools do: whitespace separates +# arguments, double quotes group them, and \" is a literal quote. +function Split-CommandLine([string] $line) { + $tokens = [System.Collections.Generic.List[string]]::new() + $current = [System.Text.StringBuilder]::new() + $inQuotes = $false + $hasToken = $false + for ($i = 0; $i -lt $line.Length; $i++) { + $ch = $line[$i] + if ($ch -eq '\') { + # 2n backslashes before a quote are n backslashes and a delimiter, + # 2n+1 are n backslashes and a literal quote; otherwise they are literal. + $count = 0 + while ($i -lt $line.Length -and $line[$i] -eq '\') { $count++; $i++ } + if ($i -lt $line.Length -and $line[$i] -eq '"') { + [void]$current.Append([char]'\', [int][Math]::Floor($count / 2)) + if ($count % 2 -eq 1) { [void]$current.Append('"') } else { $inQuotes = -not $inQuotes } + } + else { + [void]$current.Append([char]'\', $count) + $i-- + } + $hasToken = $true + continue + } + if ($ch -eq '"') { $inQuotes = -not $inQuotes; $hasToken = $true; continue } + if (-not $inQuotes -and [char]::IsWhiteSpace($ch)) { + if ($hasToken) { $tokens.Add($current.ToString()); [void]$current.Clear(); $hasToken = $false } + continue + } + [void]$current.Append($ch); $hasToken = $true + } + if ($hasToken) { $tokens.Add($current.ToString()) } + return $tokens +} + +function Get-NormalizedPath([string] $path, [string] $baseDir, [hashtable] $placeholders) { + $path = $path.Trim().TrimEnd('\', '/') + if (-not [System.IO.Path]::IsPathRooted($path) -and $baseDir) { + $path = Join-Path $baseDir $path + } + try { $path = [System.IO.Path]::GetFullPath($path) } catch { } + $path = $path.TrimEnd('\', '/') + + # Longest prefix first, so build directories win over the repository root. + foreach ($key in ($placeholders.Keys | Sort-Object Length -Descending)) { + if ($path.StartsWith($key, [System.StringComparison]::OrdinalIgnoreCase)) { + $rest = $path.Substring($key.Length).TrimStart('\', '/') + $value = $placeholders[$key] + return $(if ($rest) { "$value/$($rest -replace '\\', '/')" } else { $value }) + } + } + return ($path -replace '\\', '/') +} + +# Turns raw arguments into a sorted set of normalised options. Options with a +# separate argument (/D X, /I X) are joined, '-' is mapped to '/', and paths +# become placeholders such as , , or . +function ConvertTo-OptionSet([string[]] $arguments, [string] $baseDir, [hashtable] $placeholders, + [string[]] $sourceExtensions) { + $options = [System.Collections.Generic.List[string]]::new() + $sources = [System.Collections.Generic.List[string]]::new() + # Compiler and linker share the argument list here, so each prefix only + # applies to the tool that owns it (/I is a compiler option, /INCREMENTAL is not a path). + $isLink = $sourceExtensions -contains '.obj' + $pathOptions = if ($isLink) { @('/LIBPATH:', '/DEF:', '/MANIFESTINPUT:', '/WHOLEARCHIVE:') } + else { @('/I', '/external:I', '/FI') } + $joined = if ($isLink) { @() } else { @('/D', '/I', '/U', '/external:I', '/FI') } + + for ($i = 0; $i -lt $arguments.Count; $i++) { + $arg = $arguments[$i] + if ($arg.StartsWith('-')) { $arg = '/' + $arg.Substring(1) } + if ($arg.StartsWith('@')) { continue } + + if ($joined -contains $arg -and $i + 1 -lt $arguments.Count) { + $arg = $arg + $arguments[++$i] + } + + if (-not $arg.StartsWith('/')) { + $extension = [System.IO.Path]::GetExtension($arg) + if ($sourceExtensions -contains $extension) { + $sources.Add((Get-NormalizedPath $arg $baseDir $placeholders)) + } + elseif ($extension -eq '.lib' -or $arg.EndsWith('*.lib')) { + $name = if ([System.IO.Path]::IsPathRooted($arg)) { + Get-NormalizedPath $arg $baseDir $placeholders + } else { $arg.ToLowerInvariant() } + $options.Add("lib:$name") + } + elseif ($extension -in @('.res', '.def')) { + $options.Add("input:$([System.IO.Path]::GetFileName($arg))") + } + continue + } + + if ($ignoredOptions -contains $arg) { continue } + $skip = $false + foreach ($prefix in $ignoredOptionPrefixes) { + if ($arg.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { $skip = $true; break } + } + if ($skip) { continue } + + foreach ($prefix in $pathOptions) { + if ($arg.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase) -and $arg.Length -gt $prefix.Length) { + $arg = $prefix + (Get-NormalizedPath $arg.Substring($prefix.Length) $baseDir $placeholders) + break + } + } + $options.Add($arg) + } + + return [pscustomobject]@{ + Options = @($options | Sort-Object -Unique -CaseSensitive) + Sources = @($sources) + } +} + +function Get-Placeholders([string] $intRoot, [string] $binDir, [string] $buildDir) { + $map = @{} + $map[$repoRoot.TrimEnd('\')] = '' + foreach ($installed in @('vcpkg_installed')) { + $dir = Join-Path $repoRoot $installed + $map[$dir] = '' + } + if ($buildDir) { + $map[$buildDir.TrimEnd('\')] = '' + $map[(Join-Path $buildDir 'vcpkg_installed')] = '' + } + if ($intRoot) { $map[$intRoot.TrimEnd('\')] = '' } + if ($binDir) { $map[$binDir.TrimEnd('\')] = '' } + return $map +} + +# vcpkg install trees differ between the builds (vcpkg_installed\x64 for +# MSBuild, vcpkg_installed\cmake-x64 or similar for CMake). Collapse the +# platform folder so both report //... +function Merge-VcpkgPlatform([string[]] $options) { + return @($options | ForEach-Object { + $_ -replace '/(cmake-)?(x64|Win32|x86)/', '/' + } | Sort-Object -Unique -CaseSensitive) +} + +function Read-MSBuildCommands([string] $logPath, [string] $binDir) { + $compiles = @{} + $links = @{} + $projectDirs = @{} + Get-ChildItem -Path $repoRoot -Recurse -Filter *.vcxproj -File | + Where-Object { $_.FullName -notmatch '\\(out|obj|bin|vcpkg_installed)\\' } | + ForEach-Object { $projectDirs[$_.BaseName] = $_.DirectoryName } + + $objRoot = Join-Path $repoRoot 'obj' + foreach ($line in [System.IO.File]::ReadLines($logPath)) { + $match = [regex]::Match($line, '^\s*(?[A-Za-z]:\\.*?\\(?CL|link)\.exe)\s+(?.*)$', + [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + if (-not $match.Success) { continue } + + $arguments = Split-CommandLine $match.Groups['args'].Value + $tool = $match.Groups['tool'].Value.ToLowerInvariant() + + if ($tool -eq 'cl') { + $fo = $arguments | Where-Object { $_ -match '^[/-]Fo' } | Select-Object -First 1 + if (-not $fo) { continue } + $intDir = $fo.Substring(3).TrimEnd('\') + $project = ($intDir.Substring($objRoot.Length).TrimStart('\') -split '\\')[0] + $baseDir = $projectDirs[$project] + $target = if ($targetNames.ContainsKey($project)) { $targetNames[$project] } else { $project } + $placeholders = Get-Placeholders $intDir $binDir $null + $set = ConvertTo-OptionSet $arguments $baseDir $placeholders @('.c', '.cpp', '.cxx', '.cc') + foreach ($source in $set.Sources) { + # Keyed by target: the tests compile some ActiveX sources a second time. + $compiles["$target $source"] = [pscustomobject]@{ + Target = $target + Source = $source + Options = Merge-VcpkgPlatform $set.Options + } + } + } + else { + $out = $arguments | Where-Object { $_ -match '^[/-]OUT:' } | Select-Object -First 1 + if (-not $out) { continue } + $binary = [System.IO.Path]::GetFileName($out.Substring(5)) + # Object files are logged on separate lines; any argument inside obj\\ names the project. + $intFile = $arguments | ForEach-Object { $_ -replace '^[/-][A-Za-z]+:', '' } | + Where-Object { $_.StartsWith($objRoot, [System.StringComparison]::OrdinalIgnoreCase) } | + Select-Object -First 1 + $intDir = if ($intFile) { Split-Path $intFile -Parent } else { $null } + $project = if ($intDir) { ($intDir.Substring($objRoot.Length).TrimStart('\') -split '\\')[0] } else { $null } + $baseDir = if ($project) { $projectDirs[$project] } else { $null } + $placeholders = Get-Placeholders $intDir $binDir $null + $set = ConvertTo-OptionSet $arguments $baseDir $placeholders @('.obj') + $links[$binary] = Merge-VcpkgPlatform $set.Options + } + } + + return [pscustomobject]@{ Compiles = $compiles; Links = $links } +} + +function Read-CMakeCommands([string] $buildDir, [string] $logPath, [string] $binDir) { + $compiles = @{} + $links = @{} + + $database = Join-Path $buildDir 'compile_commands.json' + if (-not (Test-Path -LiteralPath $database)) { + throw "No compile_commands.json in $buildDir. Configure with CMAKE_EXPORT_COMPILE_COMMANDS=ON (Ninja generator)." + } + + foreach ($entry in (Get-Content -Raw -LiteralPath $database | ConvertFrom-Json)) { + $arguments = if ($entry.arguments) { @($entry.arguments | Select-Object -Skip 1) } + else { @(Split-CommandLine $entry.command | Select-Object -Skip 1) } + $fo = $arguments | Where-Object { $_ -match '^[/-]Fo' } | Select-Object -First 1 + $intDir = if ($fo) { Split-Path ([System.IO.Path]::GetFullPath((Join-Path $entry.directory $fo.Substring(3)))) -Parent } else { $null } + $placeholders = Get-Placeholders $intDir $binDir $buildDir + $set = ConvertTo-OptionSet $arguments $entry.directory $placeholders @('.c', '.cpp', '.cxx', '.cc') + $source = Get-NormalizedPath $entry.file $entry.directory $placeholders + $target = if ($intDir -match '\\CMakeFiles\\(?[^\\]+)\.dir') { $Matches['t'] } else { '' } + $compiles["$target $source"] = [pscustomobject]@{ + Target = $target + Source = $source + Options = Merge-VcpkgPlatform $set.Options + } + } + + if ($logPath) { + foreach ($line in [System.IO.File]::ReadLines($logPath)) { + # Ninja wraps links in "cmake -E vs_link_dll|vs_link_exe ... -- link.exe ..." + $match = [regex]::Match($line, '(?:^|\s|")(?[^\s"]*link\.exe"?)\s+(?.*)$', + [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + if (-not $match.Success) { continue } + $arguments = Split-CommandLine $match.Groups['args'].Value + $out = $arguments | Where-Object { $_ -match '^[/-]out:' } | Select-Object -First 1 + if (-not $out) { continue } + $binary = [System.IO.Path]::GetFileName($out.Substring(5)) + $placeholders = Get-Placeholders $null $binDir $buildDir + $set = ConvertTo-OptionSet $arguments $buildDir $placeholders @('.obj') + $links[$binary] = Merge-VcpkgPlatform $set.Options + } + } + + return [pscustomobject]@{ Compiles = $compiles; Links = $links } +} + +function Get-WarningCount([string] $logPath) { + if (-not $logPath -or -not (Test-Path -LiteralPath $logPath)) { return $null } + $unique = [System.Collections.Generic.HashSet[string]]::new() + foreach ($line in [System.IO.File]::ReadLines($logPath)) { + $match = [regex]::Match($line, '(?[^\s>][^>]*?\(\d+(?:,\d+)?\))?\s*:\s*warning\s+(?[A-Z]+\d+)\s*:(?[^\[]*)') + if ($match.Success) { + # Detailed logs repeat each warning in the project summary, so count unique ones. + [void]$unique.Add(($match.Groups['loc'].Value.Trim() + '|' + $match.Groups['code'].Value + '|' + $match.Groups['msg'].Value.Trim())) + } + } + return $unique.Count +} + +function Get-BinarySummary([string] $dumpbin, [string] $path) { + if (-not (Test-Path -LiteralPath $path)) { return $null } + + $dependents = @(& $dumpbin /nologo /dependents $path | + Where-Object { $_ -match '^\s+\S+\.(dll|DLL|ocx|exe)\s*$' } | + ForEach-Object { $_.Trim().ToLowerInvariant() } | Sort-Object -Unique) + + $exports = @(& $dumpbin /nologo /exports $path | + ForEach-Object { + $m = [regex]::Match($_, '^\s+\d+\s+[0-9A-F]+\s+(?:[0-9A-F]{8}\s+)?(?\S+)') + if ($m.Success) { $m.Groups['name'].Value } + } | Sort-Object -Unique -CaseSensitive) + + $headers = & $dumpbin /nologo /headers $path + $subsystem = ($headers | Where-Object { $_ -match 'subsystem \(' } | Select-Object -First 1) + $characteristics = @($headers | + Where-Object { $_ -match '^\s+(Dynamic base|NX compatible|High Entropy Virtual Addresses|Control Flow Guard|Guard|Terminal Server Aware|Application can handle large)' } | + ForEach-Object { $_.Trim() } | Sort-Object -Unique) + + return [pscustomobject]@{ + Dependents = $dependents + Exports = $exports + Subsystem = if ($subsystem) { $subsystem.Trim() } else { $null } + DllCharacteristics = $characteristics + } +} + +function Compare-OptionSets($reference, $actual) { + $lost = @($reference | Where-Object { $actual -cnotcontains $_ }) + $added = @($actual | Where-Object { $reference -cnotcontains $_ }) + return [pscustomobject]@{ Lost = $lost; Added = $added } +} + +# --- MSBuild baseline ------------------------------------------------------ + +if (-not $MSBuildBinDirectory) { + $MSBuildBinDirectory = Join-Path $repoRoot "bin\$Configuration\$Platform" +} + +if (-not $MSBuildLog) { + New-Item -ItemType Directory -Force -Path $parityDir | Out-Null + $MSBuildLog = Join-Path $parityDir "msbuild-$Configuration-$Platform.log" + $msbuildExe = Find-MSBuild + Write-Host "MSBuild: $msbuildExe" + # A rebuild guarantees that every translation unit appears in the log. + & $msbuildExe (Join-Path $repoRoot 'MapWinGIS6.slnx') -restore '-t:Rebuild' "-p:Configuration=$Configuration" ` + "-p:Platform=$Platform" -m '-v:m' "-flp:logfile=$MSBuildLog;verbosity=detailed" + if ($LASTEXITCODE -ne 0) { throw "MSBuild failed with exit code $LASTEXITCODE" } +} + +$MSBuildLog = (Resolve-Path -LiteralPath $MSBuildLog).Path +$msbuildCommands = Read-MSBuildCommands $MSBuildLog $MSBuildBinDirectory + +$report = [ordered]@{ + configuration = $Configuration + platform = $Platform + msbuild = [ordered]@{ + log = $MSBuildLog + warnings = Get-WarningCount $MSBuildLog + compiles = $msbuildCommands.Compiles + links = $msbuildCommands.Links + } +} + +# --- CMake side and diff --------------------------------------------------- + +$lostTotal = 0 +if ($CMakeBuildDir) { + $CMakeBuildDir = (Resolve-Path -LiteralPath $CMakeBuildDir).Path + if (-not $CMakeBinDirectory) { $CMakeBinDirectory = Join-Path $CMakeBuildDir 'bin' } + if ($CMakeBuildLog) { $CMakeBuildLog = (Resolve-Path -LiteralPath $CMakeBuildLog).Path } + $cmakeCommands = Read-CMakeCommands $CMakeBuildDir $CMakeBuildLog $CMakeBinDirectory + + $compileDiff = [ordered]@{} + # Targets that CMake does not build yet are skipped, so one phase can be + # measured before the later targets exist. + $cmakeTargets = @($cmakeCommands.Compiles.Values | ForEach-Object Target | Sort-Object -Unique) + foreach ($key in ($msbuildCommands.Compiles.Keys | Sort-Object)) { + $reference = $msbuildCommands.Compiles[$key] + if ($cmakeTargets -notcontains $reference.Target) { continue } + $actual = $cmakeCommands.Compiles[$key] + if (-not $actual) { + $compileDiff[$key] = 'missing in CMake' + $lostTotal++ + continue + } + $diff = Compare-OptionSets $reference.Options $actual.Options + if ($diff.Lost.Count -or $diff.Added.Count) { + $compileDiff[$key] = $diff + $lostTotal += $diff.Lost.Count + } + } + foreach ($key in ($cmakeCommands.Compiles.Keys | Where-Object { -not $msbuildCommands.Compiles.ContainsKey($_) })) { + $compileDiff[$key] = 'only in CMake' + } + + $linkDiff = [ordered]@{} + foreach ($binary in ($msbuildCommands.Links.Keys | Sort-Object)) { + if (-not $cmakeCommands.Links.ContainsKey($binary)) { continue } + $diff = Compare-OptionSets $msbuildCommands.Links[$binary] $cmakeCommands.Links[$binary] + if ($diff.Lost.Count -or $diff.Added.Count) { + $linkDiff[$binary] = $diff + $lostTotal += $diff.Lost.Count + } + } + + $report.cmake = [ordered]@{ + buildDir = $CMakeBuildDir + log = $CMakeBuildLog + warnings = Get-WarningCount $CMakeBuildLog + compiles = $cmakeCommands.Compiles + links = $cmakeCommands.Links + } + $report.diff = [ordered]@{ compiles = $compileDiff; links = $linkDiff } +} + +# --- Binaries -------------------------------------------------------------- + +# pwsh -File passes "a,b" as one string. +$Binaries = @($Binaries | ForEach-Object { $_ -split ',' } | ForEach-Object Trim | Where-Object { $_ }) +if ($Binaries.Count) { + $dumpbin = Find-Dumpbin + if (-not $dumpbin) { throw 'dumpbin.exe was not found; install the MSVC build tools or run from a Developer PowerShell.' } + $binaryReport = [ordered]@{} + foreach ($binary in $Binaries) { + $entry = [ordered]@{ msbuild = Get-BinarySummary $dumpbin (Join-Path $MSBuildBinDirectory $binary) } + if ($CMakeBuildDir) { $entry.cmake = Get-BinarySummary $dumpbin (Join-Path $CMakeBinDirectory $binary) } + $binaryReport[$binary] = $entry + } + $report.binaries = $binaryReport +} + +# --- Output ---------------------------------------------------------------- + +if (-not $OutputPath) { + New-Item -ItemType Directory -Force -Path $parityDir | Out-Null + $OutputPath = Join-Path $parityDir "parity-$Configuration-$Platform.json" +} +$report | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $OutputPath -Encoding utf8 + +Write-Host '' +Write-Host "Configuration: $Configuration|$Platform" +Write-Host "MSBuild TUs/links: $($msbuildCommands.Compiles.Count) / $($msbuildCommands.Links.Count)" +Write-Host "MSBuild warnings: $($report.msbuild.warnings)" +if ($CMakeBuildDir) { + Write-Host "CMake TUs/links: $($report.cmake.compiles.Count) / $($report.cmake.links.Count)" + Write-Host "CMake warnings: $($report.cmake.warnings)" + Write-Host "Lost options: $lostTotal" +} +Write-Host "Report: $OutputPath" + +if ($FailOnLostFlags -and $lostTotal -gt 0) { exit 1 } diff --git a/docs/BUILDING.md b/docs/BUILDING.md index 3ccaec5..53a2fa9 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -189,6 +189,18 @@ bin\Release\x64\MapWinGIS.Core.Tests.exe --gtest_output=xml:testresults.xml | `MwgEnableAsan` | `false` | Adds `/fsanitize=address` | | `MwgDeployRuntimeData` | `true` | Copies `gdal-data` and `proj` to the output folder | +## CMake migration + +CMake is being introduced as the primary native build. Until it is complete, +the MSBuild solution above stays the supported build. The audit of the current +MSBuild settings and the decisions the CMake build follows are in +[CMAKE-MIGRATION.md](CMAKE-MIGRATION.md). + +To measure flag and binary parity, `build\scripts\Compare-CompileCommands.ps1` +records a detailed MSBuild log and writes a normalised report to `out\parity\`. +It finds MSBuild through `MWG_MSBUILD`, `PATH` or `vswhere`, so it also works +when Visual Studio is not on `PATH` or not installed on `C:`. + ## Troubleshooting **`Invalid triplet: x64-windows-v145`** diff --git a/docs/CMAKE-MIGRATION.md b/docs/CMAKE-MIGRATION.md new file mode 100644 index 0000000..89e6eb6 --- /dev/null +++ b/docs/CMAKE-MIGRATION.md @@ -0,0 +1,304 @@ +# CMake migration: audit and decisions + +This document records what the MSBuild build actually does today and the +decisions that the CMake build follows. It is the reference for every CMake +phase: when a CMake target differs from the table here, the difference must be +deliberate and explained in the PR. + +The figures below come from the MSBuild baseline (`Debug|x64` and +`Release|x64`, VS 2026 18.11 MSBuild, MSVC 14.51, Windows SDK 10.0.28000.0), +collected with [`build/scripts/Compare-CompileCommands.ps1`](#parity-measurement-d6). + +## Targets + +| MSBuild project | Output | Type | Planned CMake target | +|---|---|---|---| +| `src/MapWinGIS.Core/MapWinGIS.Core.vcxproj` | `MapWinGIS.Core.dll` (+ `.lib`, `.pdb`) | DLL, subsystem `WINDOWS` | `MapWinGIS.Core` | +| `src/MapWinGIS.ActiveX/MapWinGIS.ActiveX.vcxproj` | `MapWinGIS.ocx` (+ `.lib`, `.pdb`, `MapWinGIS.ActiveX.tlb`) | DLL, subsystem `WINDOWS`, `UseOfAtl=Static` | `MapWinGIS.ActiveX.Interface` (MIDL) + `MapWinGIS.ActiveX` | +| `tests/MapWinGIS.Core.Tests/MapWinGIS.Core.Tests.vcxproj` | `MapWinGIS.Core.Tests.exe` | Console | `MapWinGIS.Core.Tests` | +| `samples/Cpp/Translate.vcxproj` (project name `TranslateSample`) | `translate.exe` | Console | `MapWinGIS.Samples.Translate` (`OUTPUT_NAME translate`) | +| `samples/CSharp/ActiveXTestHost/ActiveXTestHost.csproj` | `ActiveXTestHost.exe` | .NET 10 WinForms | custom targets around `dotnet build` | + +All native projects build `Debug` and `Release` for `x64` and `Win32`. + +### Sources + +**MapWinGIS.Core** — `MWG_CORE_BUILD`; include directories `include` (public) +and `src` (private). + +- Public headers: `include/mapwingis/core/{Api,Enums,Error,Extent}.h`, + `include/mapwingis/gdal/{GdalEnvironment,GdalConfig,GdalDiagnostics,GdalProgress,GdalOperationResult,GdalAlgorithmRunner,GdalTypedOptions,GdalModern,GdalUtils}.h`, + `include/mapwingis/vector/{FieldInfo,VectorDataset}.h`, + `include/mapwingis/rendering/VectorRenderScene.h`. +- Private headers: `src/internal/{GdalExecution,GdalProgressAdapterAccess,Utf8,VectorLayerAccess}.h`. +- Sources: `src/gdal/{GdalAlgorithmRunner,GdalEnvironment,GdalModern,GdalOperationResult,GdalRuntime,GdalTypedOptions,GdalUtils}.cpp`, + `src/vector/VectorDataset.cpp`, `src/rendering/VectorRenderScene.cpp`. +- No public header includes a GDAL, GEOS or Windows header, so GDAL and GEOS + can be `PRIVATE` dependencies of the DLL. + +**MapWinGIS.ActiveX** — `_ATL_APARTMENT_THREADED`; include directories +`src/MapWinGIS.Core/include` and the MIDL output directory (`$(IntDir)`). + +- Headers: `ActiveXModule.h`, `ComConversion.h`, `GdalDispatchWindow.h` + (header-only, not listed in the vcxproj), `GdalEventSource.h`, + `GdalOperationDispatcher.h`, `GdiVectorLayerRenderer.h`, + `MapWinGISControl.h`, `OpenGlVectorLayerRenderer.h`, `VectorLayerRenderer.h`, + `VectorRenderBackend.h`, `ViewTransform.h`, `resource.h`. +- Sources: `ActiveXModule.cpp`, `ComConversion.cpp`, + `GdalOperationDispatcher.cpp`, `GdalEventSource.cpp`, + `GdiVectorLayerRenderer.cpp`, `MapWinGISControl.cpp`, + `OpenGlVectorLayerRenderer.cpp`, `VectorLayerRenderer.cpp`, + `ViewTransform.cpp`, plus the generated `MapWinGIS.ActiveX_i.c` (compiled as + C, `/std:c17 /TC`). +- Other inputs: `MapWinGIS.ActiveX.idl` (MIDL), `MapWinGIS.ActiveX.rc` + (embeds `MapWinGISControl.rgs` as `REGISTRY` and the type library as + `1 TYPELIB "MapWinGIS.ActiveX.tlb"`), `MapWinGIS.ActiveX.def` (linker + `/DEF`, exports `DllCanUnloadNow`, `DllGetClassObject`, `DllRegisterServer`, + `DllUnregisterServer`, `DllInstall`). +- No `.manifest` file; the linker embeds its default manifest. + +**MapWinGIS.Core.Tests** — `MWG_ENABLE_RENDERER_TEST_HOOKS`; include +directories `src/MapWinGIS.Core/include`, `src/MapWinGIS.ActiveX` and the +ActiveX MIDL output directory `obj/MapWinGIS.ActiveX//`. + +- Headers: `TestData.h`. +- Sources: `ActiveXActivationTests.cpp`, `ComConversionTests.cpp`, + `GdalAlgorithmRunnerTests.cpp`, `GdalEnvironmentTests.cpp`, + `GdalModernCapabilitiesTests.cpp`, `GdalOperationDispatcherTests.cpp`, + `GdalRuntimeTests.cpp`, `GdalTypedOptionsTests.cpp`, `GdalUtilsTests.cpp`, + `Main.cpp` (defines `main`), `RenderingBaselineTests.cpp`, `TestData.cpp`, + `VectorDatasetTests.cpp`, `VectorLayerRendererTests.cpp`, + `VectorLayerTests.cpp`, `VectorRenderSceneTests.cpp`. +- ActiveX sources compiled again with the test hook definition: + `GdiVectorLayerRenderer.cpp`, `OpenGlVectorLayerRenderer.cpp`, + `GdalOperationDispatcher.cpp`, `ComConversion.cpp`, + `VectorLayerRenderer.cpp`, `ViewTransform.cpp`. +- Project references: Core (linked) and ActiveX (build order only, + `LinkLibraryDependencies=false`), so the OCX is beside the test executable. + +**Translate sample** — `samples/Cpp/translate.cpp` (`wmain`); include +directory `src/MapWinGIS.Core/include`. It uses only the Core public API. + +## Effective compiler options + +Common to every C/C++ translation unit (from `build/props/MapWinGIS.Cpp.props` +and MSBuild defaults): + +| Option | Source | +|---|---| +| `/W4 /WX- /wd4251 /external:W4` | `WarningLevel`, `DisableSpecificWarnings`; `/external:W4` is the MSBuild default for external headers | +| `/utf-8 /Zc:__cplusplus /Zc:preprocessor /Zc:throwingNew /bigobj` | `AdditionalOptions` | +| `/permissive- /sdl /EHsc /GS /Gd /Gm- /fp:precise /Zc:wchar_t /Zc:forScope /Zc:inline` | `ConformanceMode`, `SDLCheck`, `ExceptionHandling`, MSBuild defaults | +| `/std:c++20` (`/TP`), `/std:c17` (`/TC`, `_i.c` only) | `LanguageStandard`, `LanguageStandard_C` | +| `/Zi` | `DebugInformationFormat=ProgramDatabase`, **Debug and Release** | +| `/D UNICODE /D _UNICODE` | `CharacterSet=Unicode` | +| `/D WIN32_LEAN_AND_MEAN /D NOMINMAX /D _WIN32_WINNT=0x0A00 /D WINVER=0x0A00 /D NTDDI_VERSION=0x0A00000B /D _CRT_SECURE_NO_WARNINGS /D MWG_VERSION="6.0.0"` | `PreprocessorDefinitions` | +| `/D _WINDLL` | MSBuild adds it for every DLL project (Core, ActiveX) | +| `/diagnostics:column /FC /MP /nologo /errorReport:queue` | Logging and parallelism only; ignored by the parity script | + +Per configuration: + +| Configuration | Options | +|---|---| +| Debug | `/Od /MDd /RTC1 /JMC /D _DEBUG` (`/RTC1` is dropped when `MwgEnableAsan=true`, which adds `/fsanitize=address`) | +| Release | `/O2 /Oi /Gy /guard:cf /MD /D NDEBUG` | + +Per target: `MWG_CORE_BUILD` (Core), `_ATL_APARTMENT_THREADED` (ActiveX), +`MWG_ENABLE_RENDERER_TEST_HOOKS` (tests). + +Not defined by any project: `WIN32`, `_WINDOWS`, `_USRDLL`, `_CONSOLE`. The +CMake defaults `/DWIN32 /D_WINDOWS` are therefore removed, so the two builds +see the same preprocessor state. `/JMC` (Just My Code, a debugger aid) is an +MSBuild default for Debug that CMake does not add; CMake reproduces it. + +## Effective linker options + +| Option | Configurations | +|---|---| +| `/DEBUG` | Debug and Release (PDB for every binary) | +| `/DYNAMICBASE /NXCOMPAT /TLBID:1 /MANIFEST /manifest:embed /MANIFESTUAC:"level='asInvoker' uiAccess='false'"` | All | +| `/HIGHENTROPYVA` | x64 only (`MachineX64` also sets *large address aware*) | +| `/INCREMENTAL` | Debug | +| `/INCREMENTAL:NO /OPT:REF /OPT:ICF /guard:cf` | Release | +| `/SUBSYSTEM:WINDOWS /DLL` | Core, ActiveX | +| `/SUBSYSTEM:CONSOLE` | Tests, sample | +| `/DEF:MapWinGIS.ActiveX.def` and the compiled `.res` | ActiveX | +| `kernel32 user32 gdi32 winspool comdlg32 advapi32 shell32 ole32 oleaut32 uuid odbc32 odbccp32` | All (MSBuild default, same list as `CMAKE_CXX_STANDARD_LIBRARIES`) | +| `/[debug/]lib/*.lib` | All (`VcpkgAutoLink=true`) | + +### Real dependencies + +`VcpkgAutoLink` links every installed import library into every project and +hides what each target uses. From the sources and `dumpbin /dependents`: + +| Target | Libraries | +|---|---| +| Core | `GDAL::GDAL`, `GEOS::geos_c` (`GdalEnvironment.cpp` includes `geos_c.h` and calls `GEOSversion()`). PROJ is reached only through GDAL/OGR (`OSRSetPROJSearchPaths`). Windows: `kernel32` only. | +| ActiveX | Core, `ole32`, `oleaut32`, `uuid`, `user32`, `gdi32`, `advapi32` (registration), `opengl32` (through `#pragma comment(lib, ...)` in `OpenGlVectorLayerRenderer.cpp`), static ATL. No direct GDAL use. | +| Tests | Core, `GDAL::GDAL` (`gdal_priv.h`, `gdalalgorithm.h`, `vrtdataset.h`, `cpl_error.h`), `GTest::gtest` (not `gtest_main`: `Main.cpp` defines `main`), ATL headers (`atlbase.h`, `atlhost.h`), `ole32`, `oleaut32`, `uuid`, `user32`, `gdi32`, `opengl32`. | +| Sample | Core only. | + +`dumpbin` of the MSBuild tests shows `gmock.dll` instead of `gtest.dll`: the +auto-linked `gmock.lib` happens to export the GoogleTest symbols too. With +explicit linking the CMake build depends on `gtest.dll`; that is an expected +difference. + +## Generated files + +| File | Producer | Consumers | MSBuild location | +|---|---|---|---| +| `MapWinGIS.ActiveX_i.h` | MIDL (`/h`) | ActiveX sources, tests (`ActiveXActivationTests.cpp`) | `obj/MapWinGIS.ActiveX//` | +| `MapWinGIS.ActiveX_i.c` | MIDL (`/iid`) | Compiled into the OCX | same | +| `MapWinGIS.ActiveX_p.c`, `dlldata.c` | MIDL (`/proxy`, `/dlldata`) | None; MIDL writes no proxy for this IDL | — | +| `MapWinGIS.ActiveX.tlb` | MIDL (`/tlb`) | Embedded by `MapWinGIS.ActiveX.rc` | `bin//` | + +MIDL command line: `midl /W1 /nologo /char signed /env x64|win32 /h ... /iid ... +/proxy ... /tlb ... /target "NT60" MapWinGIS.ActiveX.idl`. The RC compiler +gets `/D UNICODE /D _UNICODE /l 0x0409` and the MIDL and output directories +as include paths. + +## Custom build steps + +- `MwgDeployGdalRuntimeData` (`Directory.Build.targets`, after `Build`, every + project unless `MwgDeployRuntimeData=false`): copies + `/share/gdal` → `/gdal-data` and `/share/proj` → + `/proj`, and warns when the GDAL data is missing. + `GdalEnvironment` looks for both folders beside the loaded module. +- `MwgInvalidateVcpkgInstallOnFeatureChange` / `MwgRecordVcpkgInstalledFeatures`: + MSBuild-only workaround for vcpkg's feature-blind stamp file. Not needed in + CMake: `vcpkg.cmake` runs `vcpkg install` on every configure with + `VCPKG_MANIFEST_FEATURES`. + +## Runtime deployment + +- `VcpkgApplocalDeps=true` copies the dependent vcpkg DLLs (GDAL, PROJ, GEOS, + curl, OpenSSL, SQLite, ...) beside each binary. +- `gdal-data/` and `proj/` (including `proj.db`) come from the custom step + above. +- All binaries share `bin//`. `ActiveXActivationTests` loads + `MapWinGIS.ocx` from the test executable's folder, and the C# host uses + registration-free COM through `app.manifest`, so the OCX must sit beside it. + +## Architecture-specific settings + +| Setting | x64 | Win32 | +|---|---|---| +| vcpkg triplet | `x64-windows-v145` | `x86-windows-v145` | +| vcpkg host triplet | `x64-windows-v145` | `x64-windows-v145` | +| MIDL `/env` | `x64` | `win32` | +| `/HIGHENTROPYVA` | yes | no | +| C# `PlatformTarget` / RID | `x64` / `win-x64` | `x86` / `win-x86` | + +## Hard-coded output paths + +These assume `/bin//`: + +- `samples/CSharp/ActiveXTestHost/Run-ActiveXTestHost.ps1` +- `tests/MapWinGIS.Core.Tests/Verify-ActiveXActivation.ps1` (OCX and test executable) +- `samples/CSharp/ActiveXTestHost/ActiveXTestHost.csproj` (`OutputPath`) +- `.github/workflows/build.yml` (test executable, Release artifact upload) + +## Prerequisites + +- Visual Studio 2026 with MSVC v145, Windows SDK 10.0.28000.0 and **C++ ATL + for latest v145 build tools for x64 and x86** (`atlmfc/lib/x64` and + `atlmfc/lib/x86`). +- CMake ≥ 3.28. VS 2026 and CLion both bundle CMake 4.3.1, which provides the + `Visual Studio 18 2026` generator and detects the v145 toolset. +- Ninja (bundled with VS and CLion) for single-config builds. +- MSBuild is only needed for the legacy build and parity comparisons. It is not + on `PATH` outside a Developer PowerShell; scripts resolve it through + `MWG_MSBUILD`, then `PATH`, then `vswhere`. + +## Decisions + +### D1. Output directory layout + +CMake writes to its build tree, never to `/bin`: runtime files to +`/bin`, import libraries to `/lib`, set once through +`CMAKE_RUNTIME_OUTPUT_DIRECTORY`, `CMAKE_LIBRARY_OUTPUT_DIRECTORY` and +`CMAKE_ARCHIVE_OUTPUT_DIRECTORY`. All targets and runtime dependencies of one +build share one runtime folder. Preset build directories are short +(`out/b/x64d`, `out/b/x64r`, `out/b/x86d`, `out/b/x86r`, `out/b/vs-x64`, +`out/b/vs-x86`) to stay clear of `MAX_PATH`. The scripts listed above gain an +optional `-BinDirectory` parameter in the phase that first needs it; their +default stays `bin//`. + +### D2. vcpkg install tree and triplets + +CMake uses its own install tree per platform, +`/vcpkg_installed/cmake-`, shared by the Debug and Release +presets and separate from the MSBuild tree `/vcpkg_installed/` +(different stamp files; a different feature set would prune packages the other +build needs). The binary cache makes the second install cheap. Presets pin +`VCPKG_TARGET_TRIPLET`, `VCPKG_HOST_TRIPLET=x64-windows-v145` and +`VCPKG_MANIFEST_FEATURES`; overlay triplets stay in `vcpkg-configuration.json`. +The vcpkg root is resolved before `project()` in the same order as +`bootstrap-vcpkg.ps1` (`MWG_VCPKG_ROOT`, sibling `../vcpkg`, `VCPKG_ROOT`), +unless `CMAKE_TOOLCHAIN_FILE` is already set. + +### D3. MIDL output ownership + +One custom target, `MapWinGIS.ActiveX.Interface`, owns the only MIDL +invocation and writes to `/src/MapWinGIS.ActiveX/midl`. A small library +target exposes that include directory and the generated `_i.c`. The OCX and +the tests both depend on that target, never on each other's intermediates. + +### D4. ActiveX sources compiled into the tests + +Option (a): one source-list variable for the six shared ActiveX sources, +compiled once into the OCX and once into the test executable with +`MWG_ENABLE_RENDERER_TEST_HOOKS`. This matches the MSBuild build and needs no +production code change. Making the hooks a runtime switch is out of scope. + +### D5. C# host project reference + +The `ProjectReference` from `ActiveXTestHost.csproj` to +`MapWinGIS.ActiveX.vcxproj` becomes conditional on +`'$(MwgCMakeBuild)' != 'true'`. The CMake custom targets pass +`-p:MwgCMakeBuild=true -p:BuildProjectReferences=false`, so `dotnet build` +never tries to build a `.vcxproj` or races CMake for the OCX. The imports in +`Directory.Build.props/.targets` are guarded by `Exists` and harmless under +`dotnet build`; the csproj already sets `MwgDeployRuntimeData=false`. + +### D6. Parity measurement + +Parity is measured with `build/scripts/Compare-CompileCommands.ps1`: + +- Compile options per translation unit and link options per binary, from the + detailed MSBuild log and from CMake's `compile_commands.json` plus a verbose + build log. Paths are normalised (``, ``, ``, + ``, ``), the vcpkg platform folder is collapsed and option + order is ignored. +- `dumpbin /dependents /exports /headers` summaries for selected binaries. +- Unique warning counts for both builds; CMake must not exceed MSBuild. + +The script runs against the MSBuild build alone (baseline) or with +`-CMakeBuildDir` for a comparison; MSBuild targets that the CMake build does +not have yet are skipped, so each phase can be measured on its own. + +```powershell +# Baseline: rebuilds MapWinGIS6.slnx with a detailed log under out\parity +pwsh -File build\scripts\Compare-CompileCommands.ps1 -Configuration Debug -Platform x64 ` + -Binaries 'MapWinGIS.Core.dll,MapWinGIS.ocx,MapWinGIS.Core.Tests.exe,translate.exe' + +# Comparison with a CMake Ninja build +cmake --build --preset x64-debug -- -v > out\parity\cmake-x64d.log +pwsh -File build\scripts\Compare-CompileCommands.ps1 -MSBuildLog out\parity\msbuild-Debug-x64.log ` + -CMakeBuildDir out\b\x64d -CMakeBuildLog out\parity\cmake-x64d.log -Binaries MapWinGIS.Core.dll +``` + +## Baseline + +| | Debug x64 | Release x64 | +|---|---|---| +| Translation units / linked binaries | 42 / 4 | 42 / 4 | +| Warnings | 0 | 0 | +| `MapWinGIS.Core.dll` dependents | `gdald`, `geos_c`, CRT | `gdal`, `geos_c`, CRT | +| `MapWinGIS.ocx` exports | 5 (`DllCanUnloadNow`, `DllGetClassObject`, `DllInstall`, `DllRegisterServer`, `DllUnregisterServer`) | 5 | +| DLL characteristics | Dynamic base, NX, high-entropy VA, large address aware | + Control Flow Guard | +| Native tests | 173 run, 170 passed, 3 skipped | — | + +The three skipped tests need `MWG_TEST_ACTIVEX_HOST`, `MWG_RUN_NETWORK_GDAL_TESTS` +or `MWG_RUN_RENDERING_BASELINE`. The Win32 baseline is collected in the phase +that first builds Win32 with CMake.