diff --git a/imGui-easyX.sln b/imGui-easyX.sln
index a42e348..3297a53 100644
--- a/imGui-easyX.sln
+++ b/imGui-easyX.sln
@@ -13,6 +13,8 @@ Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "StellarX.UiFixture", "tests
EndProject
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "EasyX.ThreadProbe", "tests\EasyX.ThreadProbe.vcxproj", "{6D338118-A536-4E4F-A4F5-3C73C5CA107A}"
EndProject
+Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "EasyX.VisualProbe", "tests\EasyX.VisualProbe.vcxproj", "{B483B71A-159A-4E67-AC2D-8BC74E07E0C1}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|x64 = Debug|x64
@@ -61,6 +63,14 @@ Global
{6D338118-A536-4E4F-A4F5-3C73C5CA107A}.Release|x64.Build.0 = Release|x64
{6D338118-A536-4E4F-A4F5-3C73C5CA107A}.Release|x86.ActiveCfg = Release|Win32
{6D338118-A536-4E4F-A4F5-3C73C5CA107A}.Release|x86.Build.0 = Release|Win32
+ {B483B71A-159A-4E67-AC2D-8BC74E07E0C1}.Debug|x64.ActiveCfg = Debug|x64
+ {B483B71A-159A-4E67-AC2D-8BC74E07E0C1}.Debug|x64.Build.0 = Debug|x64
+ {B483B71A-159A-4E67-AC2D-8BC74E07E0C1}.Debug|x86.ActiveCfg = Debug|Win32
+ {B483B71A-159A-4E67-AC2D-8BC74E07E0C1}.Debug|x86.Build.0 = Debug|Win32
+ {B483B71A-159A-4E67-AC2D-8BC74E07E0C1}.Release|x64.ActiveCfg = Release|x64
+ {B483B71A-159A-4E67-AC2D-8BC74E07E0C1}.Release|x64.Build.0 = Release|x64
+ {B483B71A-159A-4E67-AC2D-8BC74E07E0C1}.Release|x86.ActiveCfg = Release|Win32
+ {B483B71A-159A-4E67-AC2D-8BC74E07E0C1}.Release|x86.Build.0 = Release|Win32
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/scripts/analyze-visual-probe.ps1 b/scripts/analyze-visual-probe.ps1
new file mode 100644
index 0000000..976de5d
--- /dev/null
+++ b/scripts/analyze-visual-probe.ps1
@@ -0,0 +1,410 @@
+param(
+ [Parameter(Mandatory = $true)]
+ [string]$Path,
+ [Parameter(Mandatory = $true)]
+ [string]$SessionId,
+ [Parameter(Mandatory = $true)]
+ [string]$StartedAtUtc,
+ [int]$ProbeExitCode = 0
+)
+
+$ErrorActionPreference = 'Stop'
+
+function Get-FailureCategory {
+ param([object]$Case)
+ if (-not $Case.evidenceComplete) { return 'evidence_missing' }
+ if (-not $Case.targetChanged) { return 'surface_unreachable' }
+ if (-not $Case.nonTargetUnchanged) { return 'surface_cross_write' }
+ if (-not $Case.gdiStateRestored) { return 'gdi_state_not_restored' }
+ if (-not $Case.wideContentPreservedAfterEasyXFollowUp) {
+ return 'measurement_relation_invalid'
+ }
+ return $null
+}
+
+function New-WideTextCases {
+ param([object]$Raw, [object[]]$EvidenceFiles)
+ $items = New-Object System.Collections.Generic.List[object]
+ foreach ($case in @($Raw.result.cases)) {
+ $caseFailure = Get-FailureCategory -Case $case
+ $caseEvidence = @($EvidenceFiles | Where-Object {
+ $_.path -like "evidence/$($case.testCaseId)_*"
+ })
+ $phases = @('after_draw')
+ if ($case.batchState -eq 'inside') { $phases += @('after_flush', 'after_end') }
+ foreach ($text in @($case.textTests)) {
+ $textFailure = $caseFailure
+ if (-not $textFailure -and (-not $text.isValidUtf16 -or
+ -not $text.getTextExtentPoint32W -or -not $text.textOutW -or
+ [int]$text.drawTextWHeight -le 0)) {
+ $textFailure = 'measurement_relation_invalid'
+ }
+ foreach ($phase in $phases) {
+ $fingerprint = switch ($phase) {
+ 'after_draw' { $case.observations.afterDraw; break }
+ 'after_flush' { $case.observations.afterFlush; break }
+ 'after_end' { $case.observations.afterEnd; break }
+ }
+ $status = if ($textFailure) { 'fail' } else { 'pass' }
+ $items.Add([pscustomobject][ordered]@{
+ testCaseId = "$($case.testCaseId)/$($text.testStringId)/$phase"
+ testStringId = $text.testStringId
+ codePoints = @($text.codePoints)
+ utf16Units = @($text.utf16Units)
+ surfaceId = $case.surfaceId
+ batchState = $case.batchState
+ observationPhase = $phase
+ hdc = $fingerprint.hdc
+ hbitmap = $fingerprint.hbitmap
+ hfont = $fingerprint.hfont
+ bitmapWidth = $fingerprint.bitmapWidth
+ bitmapHeight = $fingerprint.bitmapHeight
+ planes = $fingerprint.planes
+ bitsPerPixel = $fingerprint.bitsPerPixel
+ stride = $fingerprint.stride
+ dibSection = $fingerprint.dibSection
+ logFont = $fingerprint.logFont
+ gdiStateBefore = $case.before.gdiState
+ gdiStateAfter = $fingerprint.gdiState
+ apiResults = [pscustomobject][ordered]@{
+ getTextExtentPoint32W = $text.getTextExtentPoint32W
+ textOutW = $text.textOutW
+ drawTextWHeight = $text.drawTextWHeight
+ }
+ logicalExtent = $text.logicalExtent
+ textOutInkBounds = $text.textOutInkBounds
+ drawTextInkBounds = $text.drawTextInkBounds
+ targetPixelsBefore = $case.before.pixelDigest
+ targetPixelsAfter = $fingerprint.pixelDigest
+ controlPixelsBefore = $case.nonTargetBefore.pixelDigest
+ controlPixelsAfter = $case.nonTargetAfterDraw.pixelDigest
+ easyXFollowUp = [pscustomobject][ordered]@{
+ wideContentPreserved = $case.wideContentPreservedAfterEasyXFollowUp
+ surfaceFingerprint = $case.observations.afterEasyXFollowUp
+ }
+ automatedStatus = $status
+ manualStatus = 'not_reviewed'
+ failureCategory = $textFailure
+ evidenceFiles = $caseEvidence
+ })
+ }
+ }
+ }
+ return $items.ToArray()
+}
+
+function New-DpiCoordinateCases {
+ param([object]$Raw, [object[]]$EvidenceFiles)
+ $failure = $null
+ if ($EvidenceFiles.Count -lt @($Raw.evidenceFiles).Count) { $failure = 'evidence_missing' }
+ elseif (-not $Raw.result.gdiStateRestored) { $failure = 'gdi_state_not_restored' }
+ elseif (-not $Raw.result.roundTripsExact) { $failure = 'coordinate_transform_invalid' }
+ elseif (-not $Raw.result.gdiExtentSuccess -or -not $Raw.result.gdiTextSuccess) {
+ $failure = 'measurement_relation_invalid'
+ }
+ $status = if ($failure) { 'fail' } else { 'pass' }
+ $decision = if ($Raw.result.implicitScalingObserved) {
+ 'requires_explicit_transform_validation'
+ }
+ else {
+ 'direct_hwnd_relative_coordinates_supported_at_observed_dpi'
+ }
+ return @([pscustomobject][ordered]@{
+ testCaseId = 'dpi-coordinate-current-environment'
+ surfaceId = 'main'
+ batchState = $Raw.result.batchState
+ observationPhase = 'after_end'
+ dpiAwareness = [pscustomobject][ordered]@{
+ process = $Raw.environment.processAwareness
+ thread = $Raw.environment.threadAwareness
+ window = $Raw.environment.windowAwareness
+ }
+ windowDpi = $Raw.environment.dpiForWindow
+ clientRect = $Raw.environment.clientRect
+ windowScreenRect = $Raw.environment.windowRect
+ surfaceSize = [pscustomobject]@{ width = 960; height = 720 }
+ surfaceFingerprint = $Raw.result.surfaceFingerprintAfterDraw
+ surfaceBounds = $Raw.result.surfaceBounds
+ clientBounds = $Raw.result.clientBounds
+ coordinateRoundTrips = @($Raw.result.coordinateRoundTrips)
+ surfaceMarkerSizesAgree = $Raw.result.surfaceMarkerSizesAgree
+ clientMatchesSurface = $Raw.result.clientMatchesSurface
+ implicitScalingObserved = $Raw.result.implicitScalingObserved
+ coordinateDecision = $decision
+ automatedStatus = $status
+ manualStatus = 'not_reviewed'
+ failureCategory = $failure
+ evidenceFiles = $EvidenceFiles
+ })
+}
+
+function New-ImePositionCases {
+ param([object]$Raw, [object[]]$EvidenceFiles)
+ $items = New-Object System.Collections.Generic.List[object]
+ foreach ($anchor in @($Raw.result.anchors)) {
+ $events = @($Raw.result.imeEvents | Where-Object {
+ $_.syntheticAnchorId -eq $anchor.syntheticAnchorId -and $_.apiAttempted
+ })
+ $successful = @($events | Where-Object {
+ $_.contextAvailable -and $_.compositionForm.apiResult -and $_.candidateForm.apiResult
+ })
+ $preconfigureEvents = @($events | Where-Object {
+ $_.positionTrigger -eq 'anchor_change' -or $_.stage -eq 'anchor_preconfigure'
+ })
+ $successfulPreconfigure = @($preconfigureEvents | Where-Object {
+ $_.contextAvailable -and $_.compositionForm.apiResult -and $_.candidateForm.apiResult
+ })
+ $lifecycleEvents = @($events | Where-Object {
+ $_.positionTrigger -eq 'ime_lifecycle' -or
+ $_.stage -eq 'ime_start' -or $_.stage -eq 'ime_composition'
+ })
+ $successfulLifecycle = @($lifecycleEvents | Where-Object {
+ $_.contextAvailable -and $_.compositionForm.apiResult -and $_.candidateForm.apiResult
+ })
+ $latest = $events | Select-Object -Last 1
+ $failure = if ($successful.Count -gt 0) { $null } else { 'ime_position_invalid' }
+ $status = if ($failure) { 'fail' } else { 'pass' }
+ $anchorEvidence = @($EvidenceFiles | Where-Object {
+ $_.path -like "evidence/$($anchor.syntheticAnchorId)_*" -or
+ $_.path -like "manual-evidence/$($anchor.syntheticAnchorId)_*"
+ })
+ $items.Add([pscustomobject][ordered]@{
+ testCaseId = "ime-position/$($anchor.syntheticAnchorId)"
+ syntheticAnchorId = $anchor.syntheticAnchorId
+ anchorCoordinates = [pscustomobject][ordered]@{
+ controlOrigin = $anchor.controlOrigin
+ controlLocalCaretTopLeft = $anchor.controlLocalCaretTopLeft
+ controlLocalCaretBottom = $anchor.controlLocalCaretBottom
+ controlLocalLineRect = $anchor.controlLocalLineRect
+ easyxSurfaceCaretTopLeft = $anchor.easyxSurfaceCaretTopLeft
+ easyxSurfaceCaretBottom = $anchor.easyxSurfaceCaretBottom
+ easyxSurfaceLineRect = $anchor.easyxSurfaceLineRect
+ targetHwndRelativePoint = $anchor.targetHwndRelativePoint
+ diagnosticScreenPoint = if ($latest) { $latest.diagnosticScreenPoint } else { $null }
+ }
+ compositionForm = if ($latest) { $latest.compositionForm } else { $null }
+ candidateForm = if ($latest) { $latest.candidateForm } else { $null }
+ imeId = if ($latest) { $latest.imeDescriptionUtf16 } else { @() }
+ imeFileName = if ($latest) { $latest.imeFileNameUtf16 } else { @() }
+ hkl = if ($latest) { $latest.hkl } else { $null }
+ apiAttemptCount = $events.Count
+ successfulApiAttemptCount = $successful.Count
+ preconfigureApiAttemptCount = $preconfigureEvents.Count
+ successfulPreconfigureApiAttemptCount = $successfulPreconfigure.Count
+ lifecycleApiAttemptCount = $lifecycleEvents.Count
+ successfulLifecycleApiAttemptCount = $successfulLifecycle.Count
+ positionDisposition = 'not_observable'
+ automatedStatus = $status
+ manualStatus = 'not_reviewed'
+ failureCategory = $failure
+ evidenceFiles = $anchorEvidence
+ })
+ }
+ return $items.ToArray()
+}
+
+$resolved = Resolve-Path -LiteralPath $Path
+$item = Get-Item -LiteralPath $resolved
+$directory = if ($item.PSIsContainer) { $item.FullName } else { $item.DirectoryName }
+$rawPath = if ($item.PSIsContainer) { Join-Path $directory 'raw-result.json' } else { $item.FullName }
+if (-not (Test-Path -LiteralPath $rawPath)) { throw "Raw result not found: $rawPath" }
+
+$raw = Get-Content -Raw -LiteralPath $rawPath | ConvertFrom-Json
+if ([int]$raw.rawSchemaVersion -ne 1) { throw "Unsupported raw schema: $($raw.rawSchemaVersion)" }
+if ($raw.mode -notin @('wide-text', 'dpi-coordinate', 'ime-position')) {
+ throw "Unsupported probe mode: $($raw.mode)"
+}
+
+$evidence = New-Object System.Collections.Generic.List[object]
+foreach ($entry in @($raw.evidenceFiles)) {
+ $relativePath = [string]$entry.path
+ $nativeRelative = $relativePath.Replace('/', [IO.Path]::DirectorySeparatorChar)
+ $fullPath = Join-Path $directory $nativeRelative
+ if (-not (Test-Path -LiteralPath $fullPath)) { continue }
+ $file = Get-Item -LiteralPath $fullPath
+ $evidence.Add([pscustomobject][ordered]@{
+ path = $relativePath
+ purpose = $entry.purpose
+ captureBackend = $entry.captureBackend
+ sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $file.FullName).Hash
+ byteLength = $file.Length
+ crop = $null
+ })
+}
+$rawEvidencePresent = $evidence.Count
+$manualEvidenceDirectory = Join-Path $directory 'manual-evidence'
+if (Test-Path -LiteralPath $manualEvidenceDirectory) {
+ Get-ChildItem -LiteralPath $manualEvidenceDirectory -Filter '*.png' -File | ForEach-Object {
+ $evidence.Add([pscustomobject][ordered]@{
+ path = 'manual-evidence/' + $_.Name
+ purpose = 'manually cropped IME position evidence'
+ captureBackend = 'manual_crop'
+ sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName).Hash
+ byteLength = $_.Length
+ crop = 'user_supplied'
+ })
+ }
+}
+
+$evidenceArray = $evidence.ToArray()
+$testCases = if ($raw.mode -eq 'wide-text') {
+ New-WideTextCases -Raw $raw -EvidenceFiles $evidenceArray
+}
+elseif ($raw.mode -eq 'dpi-coordinate') {
+ New-DpiCoordinateCases -Raw $raw -EvidenceFiles $evidenceArray
+}
+else {
+ New-ImePositionCases -Raw $raw -EvidenceFiles $evidenceArray
+}
+
+$overallManualStatus = 'not_reviewed'
+$sourceManualReview = $null
+$manualReviewSummary = $null
+$manualReviewPath = Join-Path $directory 'manual-review-v1.json'
+if (Test-Path -LiteralPath $manualReviewPath) {
+ $manualReview = Get-Content -Raw -LiteralPath $manualReviewPath | ConvertFrom-Json
+ if ([int]$manualReview.manualReviewSchemaVersion -ne 1) {
+ throw "Unsupported manual review schema: $($manualReview.manualReviewSchemaVersion)"
+ }
+ if ([string]$manualReview.mode -ne [string]$raw.mode) {
+ throw "Manual review mode does not match raw result: $($manualReview.mode)"
+ }
+ $allowedManualStatuses = @('pass', 'fail', 'inconclusive', 'not_reviewed', 'not_available')
+ $allowedPositionDispositions = @('as_requested', 'system_adjusted', 'not_observable')
+ $reviewedIds = New-Object System.Collections.Generic.List[string]
+ foreach ($reviewCase in @($manualReview.testCases)) {
+ $testCaseId = [string]$reviewCase.testCaseId
+ if ($reviewedIds.Contains($testCaseId)) { throw "Duplicate manual review case: $testCaseId" }
+ $reviewedIds.Add($testCaseId)
+ $matchingCases = @($testCases | Where-Object testCaseId -eq $testCaseId)
+ if ($matchingCases.Count -ne 1) { throw "Unknown manual review case: $testCaseId" }
+ $manualStatus = [string]$reviewCase.manualStatus
+ $positionDisposition = [string]$reviewCase.positionDisposition
+ if ($manualStatus -notin $allowedManualStatuses) {
+ throw "Unsupported manual status for ${testCaseId}: $manualStatus"
+ }
+ if ($positionDisposition -notin $allowedPositionDispositions) {
+ throw "Unsupported position disposition for ${testCaseId}: $positionDisposition"
+ }
+ $targetCase = $matchingCases[0]
+ if ($manualStatus -eq 'pass' -and $targetCase.automatedStatus -ne 'pass') {
+ throw "Manual pass cannot override automated failure: $testCaseId"
+ }
+ $reviewEvidence = @($reviewCase.evidenceFiles)
+ if ($manualStatus -eq 'pass' -and $reviewEvidence.Count -eq 0) {
+ throw "Manual pass requires explicit evidence files: $testCaseId"
+ }
+ foreach ($relativePath in $reviewEvidence) {
+ $relative = [string]$relativePath
+ if ($relative -notlike 'manual-evidence/*') {
+ throw "Manual review evidence must be under manual-evidence: $relative"
+ }
+ $matchingEvidence = @($targetCase.evidenceFiles | Where-Object path -eq $relative)
+ if ($matchingEvidence.Count -ne 1) {
+ throw "Manual review evidence is missing or belongs to another case: $relative"
+ }
+ }
+ if ($manualStatus -eq 'pass' -and $positionDisposition -eq 'not_observable') {
+ throw "Manual pass requires an observable position disposition: $testCaseId"
+ }
+ $targetCase.manualStatus = $manualStatus
+ $targetCase.positionDisposition = $positionDisposition
+ $targetCase | Add-Member -NotePropertyName manualReviewNotes `
+ -NotePropertyValue ([string]$reviewCase.notes) -Force
+ }
+ if ($reviewedIds.Count -ne @($testCases).Count) {
+ throw 'Manual review must cover every manifest test case'
+ }
+ $caseManualStatuses = @($testCases | ForEach-Object { $_.manualStatus })
+ $derivedManualStatus = if ($caseManualStatuses -contains 'fail') {
+ 'fail'
+ }
+ elseif ($caseManualStatuses -contains 'inconclusive') {
+ 'inconclusive'
+ }
+ elseif ($caseManualStatuses -contains 'not_reviewed') {
+ 'not_reviewed'
+ }
+ elseif ($caseManualStatuses -contains 'not_available') {
+ if (@($caseManualStatuses | Where-Object { $_ -ne 'not_available' }).Count -eq 0) {
+ 'not_available'
+ }
+ else {
+ 'inconclusive'
+ }
+ }
+ else {
+ 'pass'
+ }
+ if ([string]$manualReview.overallStatus -ne $derivedManualStatus) {
+ throw "Manual review overall status mismatch: expected $derivedManualStatus"
+ }
+ $overallManualStatus = $derivedManualStatus
+ $sourceManualReview = [pscustomobject][ordered]@{
+ path = 'manual-review-v1.json'
+ sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $manualReviewPath).Hash
+ }
+ $manualReviewSummary = [pscustomobject][ordered]@{
+ reviewedAtUtc = $manualReview.reviewedAtUtc
+ reviewer = $manualReview.reviewer
+ expectedImeName = $manualReview.expectedImeName
+ actualImeName = $manualReview.actualImeName
+ identitySource = $manualReview.identitySource
+ notes = $manualReview.notes
+ }
+}
+
+$failedCases = @($testCases | Where-Object automatedStatus -eq 'fail')
+$allEvidencePresent = $rawEvidencePresent -eq @($raw.evidenceFiles).Count
+$overallFailure = $null
+if (-not $allEvidencePresent) { $overallFailure = 'evidence_missing' }
+elseif ($failedCases.Count -gt 0) { $overallFailure = $failedCases[0].failureCategory }
+elseif (-not $raw.completed -or $ProbeExitCode -ne 0) { $overallFailure = 'inconclusive_probe_exit' }
+$overallStatus = if ($overallFailure) { 'fail' } else { 'pass' }
+
+$manifest = [pscustomobject][ordered]@{
+ visualProbeSchemaVersion = 1
+ sessionId = $SessionId
+ startedAtUtc = $StartedAtUtc
+ completedAtUtc = [DateTime]::UtcNow.ToString('o')
+ mode = $raw.mode
+ processId = $raw.environment.processId
+ threadId = $raw.environment.threadId
+ osBuild = $raw.environment.osBuild
+ easyxVersion = $raw.environment.easyxVersion
+ probeExecutableExitCode = $ProbeExitCode
+ dpiConfiguration = $raw.dpiConfiguration
+ environment = $raw.environment
+ automatedStatus = $overallStatus
+ manualStatus = $overallManualStatus
+ failureCategory = $overallFailure
+ sourceRawResult = [pscustomobject][ordered]@{
+ path = 'raw-result.json'
+ sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $rawPath).Hash
+ }
+ sourceManualReview = $sourceManualReview
+ manualReview = $manualReviewSummary
+ evidenceFiles = $evidenceArray
+ testCases = @($testCases)
+}
+
+$manifestPath = Join-Path $directory 'manifest-v1.json'
+$manifestJson = $manifest | ConvertTo-Json -Depth 100
+$utf8Bom = New-Object System.Text.UTF8Encoding($true)
+[IO.File]::WriteAllText($manifestPath, $manifestJson + [Environment]::NewLine, $utf8Bom)
+$manifestHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $manifestPath).Hash
+$sidecarPath = $manifestPath + '.sha256'
+$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
+[IO.File]::WriteAllText($sidecarPath, "$manifestHash *manifest-v1.json`r`n", $utf8NoBom)
+
+[pscustomobject][ordered]@{
+ mode = $raw.mode
+ manifestPath = $manifestPath
+ manifestSha256 = $manifestHash
+ automatedStatus = $overallStatus
+ manualStatus = $overallManualStatus
+ testCaseCount = @($testCases).Count
+ evidenceCount = $evidence.Count
+ dpiForWindow = $raw.environment.dpiForWindow
+ windowAwareness = $raw.environment.windowAwareness
+}
diff --git a/scripts/run-visual-probe.ps1 b/scripts/run-visual-probe.ps1
new file mode 100644
index 0000000..ae2968e
--- /dev/null
+++ b/scripts/run-visual-probe.ps1
@@ -0,0 +1,115 @@
+param(
+ [ValidateSet('Debug', 'Release')]
+ [string]$Configuration = 'Debug',
+ [ValidateSet('x64', 'Win32')]
+ [string]$Platform = 'x64',
+ [ValidateSet('All', 'wide-text', 'dpi-coordinate', 'ime-position')]
+ [string]$Mode = 'All',
+ [ValidateSet('inherit', 'unaware', 'system', 'per-monitor', 'per-monitor-v2')]
+ [string]$DpiAwareness = 'inherit',
+ [ValidateSet('unspecified', 'MicrosoftPinyin', 'Sogou')]
+ [string]$ImeName = 'unspecified'
+)
+
+$ErrorActionPreference = 'Stop'
+$root = Split-Path -Parent $PSScriptRoot
+$msbuild = if ($env:STELLARX_MSBUILD) { $env:STELLARX_MSBUILD } else { 'D:\vs2022\MSBuild\Current\Bin\MSBuild.exe' }
+$project = Join-Path $root 'tests\EasyX.VisualProbe.vcxproj'
+$probe = Join-Path $root "build\$Platform\$Configuration\EasyX.VisualProbe.exe"
+if (-not (Test-Path -LiteralPath $msbuild)) { throw "MSBuild not found: $msbuild" }
+
+$pathKeyCount = @(
+ [Environment]::GetEnvironmentVariables([EnvironmentVariableTarget]::Process).Keys |
+ Where-Object { [string]$_ -ieq 'Path' }
+).Count
+if ($pathKeyCount -gt 1) {
+ $buildCommand = 'set "PATH=" & "' + $msbuild + '" "' + $project +
+ '" /m /t:Build /p:Configuration=' + $Configuration + ' /p:Platform=' + $Platform + ' /v:minimal'
+ & $env:ComSpec /d /c $buildCommand
+}
+else {
+ & $msbuild $project /m /t:Build /p:Configuration=$Configuration /p:Platform=$Platform /v:minimal
+}
+if ($LASTEXITCODE -ne 0) { throw 'Visual probe build failed' }
+if (-not (Test-Path -LiteralPath $probe)) { throw "Visual probe executable not found: $probe" }
+
+$sessionId = [guid]::NewGuid().ToString()
+$stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff'
+$sessionRoot = Join-Path $root "TestResults\VisualProbe\$stamp-$sessionId"
+New-Item -ItemType Directory -Force -Path $sessionRoot | Out-Null
+$modes = if ($Mode -eq 'All') { @('wide-text', 'dpi-coordinate') } else { @($Mode) }
+$summaries = New-Object System.Collections.Generic.List[object]
+$probeFailed = $false
+
+foreach ($probeMode in $modes) {
+ $startedAtUtc = [DateTime]::UtcNow.ToString('o')
+ $runDirectory = Join-Path $sessionRoot $probeMode
+ New-Item -ItemType Directory -Force -Path $runDirectory | Out-Null
+ if ($probeMode -eq 'ime-position') {
+ $manualEvidence = Join-Path $runDirectory 'manual-evidence'
+ New-Item -ItemType Directory -Force -Path $manualEvidence | Out-Null
+ Write-Host "Manual IME position probe: $ImeName"
+ Write-Host ' 1. Select the requested IME and click the EasyX window.'
+ Write-Host ' 2. Press F1, type ceshi, inspect the candidate position, then press Escape.'
+ Write-Host ' 3. Repeat with F2 and F3. Use only the fixed non-sensitive input ceshi.'
+ Write-Host ' 4. At F3, commit once with Space; switch back to F1, type ceshi, and verify the old F3 position is not reused.'
+ Write-Host ' 5. To close 0C-3, save tightly cropped screenshots with anchor-id prefixes under:'
+ Write-Host " $manualEvidence"
+ Write-Host ' 6. Close the EasyX window when all three anchors and the post-commit F1 regression have been exercised.'
+ }
+ Write-Host "Running visual probe mode: $probeMode"
+ & $probe --mode $probeMode --output $runDirectory --dpi-awareness $DpiAwareness --ime-name $ImeName
+ $exitCode = $LASTEXITCODE
+ $rawPath = Join-Path $runDirectory 'raw-result.json'
+ if (-not (Test-Path -LiteralPath $rawPath)) {
+ throw "Visual probe did not produce raw-result.json (exit $exitCode): $runDirectory"
+ }
+ $summary = & (Join-Path $PSScriptRoot 'analyze-visual-probe.ps1') `
+ -Path $runDirectory -SessionId $sessionId -StartedAtUtc $startedAtUtc -ProbeExitCode $exitCode
+ $summaries.Add($summary)
+ if ($exitCode -ne 0 -or $summary.automatedStatus -ne 'pass') { $probeFailed = $true }
+}
+
+if ($summaries.Count -eq 2) {
+ $drawing = $summaries | Where-Object mode -eq 'wide-text' | Select-Object -First 1
+ $coordinate = $summaries | Where-Object mode -eq 'dpi-coordinate' | Select-Object -First 1
+ $combinedInput = "$($drawing.manifestSha256):$($coordinate.manifestSha256)"
+ $sha = [Security.Cryptography.SHA256]::Create()
+ try {
+ $combinedBytes = [Text.Encoding]::UTF8.GetBytes($combinedInput)
+ $combinedHash = ([BitConverter]::ToString($sha.ComputeHash($combinedBytes))).Replace('-', '')
+ }
+ finally {
+ $sha.Dispose()
+ }
+ $baseline = [pscustomobject][ordered]@{
+ visualProbeBaselineSchemaVersion = 1
+ sessionId = $sessionId
+ createdAtUtc = [DateTime]::UtcNow.ToString('o')
+ drawingBaselineManifest = 'wide-text/manifest-v1.json'
+ coordinateBaselineManifest = 'dpi-coordinate/manifest-v1.json'
+ drawingBaselineSha256 = $drawing.manifestSha256
+ coordinateBaselineSha256 = $coordinate.manifestSha256
+ combinedBaselineId = "sha256:$combinedHash"
+ dpiAwareness = $coordinate.windowAwareness
+ dpiForWindow = $coordinate.dpiForWindow
+ imeIdentity = $null
+ hkl = $null
+ automatedStatus = if ($probeFailed) { 'fail' } else { 'pass' }
+ manualStatus = 'not_reviewed'
+ }
+ $baselinePath = Join-Path $sessionRoot 'baseline-set-v1.json'
+ $utf8Bom = New-Object System.Text.UTF8Encoding($true)
+ [IO.File]::WriteAllText($baselinePath,
+ ($baseline | ConvertTo-Json -Depth 20) + [Environment]::NewLine, $utf8Bom)
+ $baselineHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $baselinePath).Hash
+ $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
+ [IO.File]::WriteAllText($baselinePath + '.sha256',
+ "$baselineHash *baseline-set-v1.json`r`n", $utf8NoBom)
+ Write-Host "Combined baseline: $baselinePath"
+ Write-Host "Combined baseline ID: $($baseline.combinedBaselineId)"
+}
+
+$summaries | Format-Table mode, automatedStatus, manualStatus, testCaseCount, evidenceCount, dpiForWindow
+Write-Host "Result directory: $sessionRoot"
+if ($probeFailed) { throw "One or more visual probe modes did not pass; evidence was preserved in $sessionRoot" }
diff --git a/tests/EasyX.VisualProbe.vcxproj b/tests/EasyX.VisualProbe.vcxproj
new file mode 100644
index 0000000..4e4af06
--- /dev/null
+++ b/tests/EasyX.VisualProbe.vcxproj
@@ -0,0 +1,55 @@
+
+
+
+ DebugWin32
+ ReleaseWin32
+ Debugx64
+ Releasex64
+
+
+ 17.0
+ Win32Proj
+ {B483B71A-159A-4E67-AC2D-8BC74E07E0C1}
+ EasyXVisualProbe
+ 10.0
+
+
+
+ Application
+ true
+ false
+ v143
+ MultiByte
+
+
+
+ $(ProjectDir)..\build\$(Platform)\$(Configuration)\
+ $(ProjectDir)..\build\obj\$(ProjectName)\$(Platform)\$(Configuration)\
+ EasyX.VisualProbe
+
+
+
+ Level4
+ true
+ true
+ stdcpp17
+ NotUsing
+ WIN32_LEAN_AND_MEAN;NOMINMAX;_WIN32_WINNT=0x0A00;%(PreprocessorDefinitions)
+
+
+ Console
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/visual-probe/DpiCoordinateProbe.cpp b/tests/visual-probe/DpiCoordinateProbe.cpp
new file mode 100644
index 0000000..0f2b761
--- /dev/null
+++ b/tests/visual-probe/DpiCoordinateProbe.cpp
@@ -0,0 +1,270 @@
+#include "VisualProbeCommon.h"
+
+#include
+#include
+#include
+#include
+#include
+
+namespace VisualProbe
+{
+ namespace
+ {
+ constexpr COLORREF Background = RGB(238, 241, 246);
+ constexpr COLORREF EasyXColor = RGB(220, 45, 70);
+ constexpr COLORREF GdiColor = RGB(20, 150, 70);
+
+ struct RoundTripResult
+ {
+ POINT client{};
+ POINT screen{};
+ POINT clientRoundTrip{};
+ int errorX = 0;
+ int errorY = 0;
+ bool converted = false;
+ };
+
+ std::string normalizedRelative(const std::filesystem::path& path)
+ {
+ std::string value = path.generic_string();
+ std::replace(value.begin(), value.end(), '\\', '/');
+ return value;
+ }
+
+ bool addSurfaceEvidence(
+ const Options& options,
+ HDC hdc,
+ const std::string& fileName,
+ const std::string& purpose,
+ std::vector& evidence)
+ {
+ const auto relative = std::filesystem::path("evidence") / fileName;
+ const bool saved = saveHdcImage(hdc, CanvasWidth, CanvasHeight,
+ options.outputDirectory / relative);
+ if (saved) evidence.push_back({ normalizedRelative(relative), purpose, "surface_bitblt" });
+ return saved;
+ }
+
+ bool addClientEvidence(
+ const Options& options,
+ HWND hwnd,
+ const std::string& fileName,
+ const std::string& purpose,
+ std::vector& evidence)
+ {
+ const auto relative = std::filesystem::path("evidence") / fileName;
+ Sleep(30);
+ std::string backend;
+ const bool saved = captureClientImage(hwnd, options.outputDirectory / relative, &backend);
+ if (saved) evidence.push_back({ normalizedRelative(relative), purpose, backend });
+ return saved;
+ }
+
+ RoundTripResult roundTrip(HWND hwnd, POINT client)
+ {
+ RoundTripResult result;
+ result.client = client;
+ result.screen = client;
+ if (!ClientToScreen(hwnd, &result.screen)) return result;
+ result.clientRoundTrip = result.screen;
+ if (!ScreenToClient(hwnd, &result.clientRoundTrip)) return result;
+ result.converted = true;
+ result.errorX = result.clientRoundTrip.x - result.client.x;
+ result.errorY = result.clientRoundTrip.y - result.client.y;
+ return result;
+ }
+
+ int boundsWidth(const PixelBounds& bounds)
+ {
+ return bounds.present ? bounds.right - bounds.left + 1 : 0;
+ }
+
+ int boundsHeight(const PixelBounds& bounds)
+ {
+ return bounds.present ? bounds.bottom - bounds.top + 1 : 0;
+ }
+
+ void writeRoundTrip(std::ostream& output, const RoundTripResult& result)
+ {
+ output << "{\"converted\":";
+ writeBoolean(output, result.converted);
+ output << ",\"client\":";
+ writePointJson(output, result.client);
+ output << ",\"screen\":";
+ writePointJson(output, result.screen);
+ output << ",\"clientRoundTrip\":";
+ writePointJson(output, result.clientRoundTrip);
+ output << ",\"error\":{\"x\":" << result.errorX
+ << ",\"y\":" << result.errorY << "}}";
+ }
+ }
+
+ int runDpiCoordinateProbe(const Options& options, const DpiConfiguration& dpiConfiguration)
+ {
+ HWND hwnd = initgraph(CanvasWidth, CanvasHeight, EX_SHOWCONSOLE);
+ if (!hwnd)
+ {
+ std::cerr << "initgraph failed\n";
+ return 3;
+ }
+ SetWindowTextA(hwnd, "StellarX 0C DPI Coordinate Probe");
+ ShowWindow(hwnd, SW_SHOWNORMAL);
+ SetForegroundWindow(hwnd);
+ Sleep(80);
+ const EnvironmentSnapshot environment = captureEnvironment(hwnd);
+ SetWorkingImage(nullptr);
+ HDC hdc = GetImageHDC(nullptr);
+ std::vector evidence;
+ bool evidenceComplete = true;
+
+ BeginBatchDraw();
+ setbkcolor(Background);
+ cleardevice();
+ setlinestyle(PS_SOLID, 1);
+ setlinecolor(EasyXColor);
+ rectangle(100, 100, 240, 180);
+ line(100, 140, 240, 140);
+ line(170, 100, 170, 180);
+ settextcolor(EasyXColor);
+ setbkmode(TRANSPARENT);
+ settextstyle(28, 0, "Microsoft YaHei UI");
+ const char* easyXText = "StellarX DPI";
+ outtextxy(100, 250, easyXText);
+ const SIZE easyXLogicalExtent{ textwidth(easyXText), textheight(easyXText) };
+
+ const GdiState stateBefore = captureGdiState(hdc);
+ const int saved = SaveDC(hdc);
+ HPEN pen = CreatePen(PS_SOLID, 1, GdiColor);
+ HGDIOBJ previousPen = pen ? SelectObject(hdc, pen) : nullptr;
+ HBRUSH previousBrush = static_cast(SelectObject(hdc, GetStockObject(NULL_BRUSH)));
+ SetBkMode(hdc, TRANSPARENT);
+ SetTextColor(hdc, GdiColor);
+ Rectangle(hdc, 500, 100, 641, 181);
+ MoveToEx(hdc, 500, 140, nullptr);
+ LineTo(hdc, 641, 140);
+ MoveToEx(hdc, 570, 100, nullptr);
+ LineTo(hdc, 570, 181);
+ const std::wstring gdiText = L"StellarX DPI";
+ SIZE gdiLogicalExtent{};
+ const bool gdiExtentSuccess = GetTextExtentPoint32W(hdc, gdiText.data(),
+ static_cast(gdiText.size()), &gdiLogicalExtent) != FALSE;
+ const bool gdiTextSuccess = TextOutW(hdc, 500, 250, gdiText.data(),
+ static_cast(gdiText.size())) != FALSE;
+ if (previousBrush) SelectObject(hdc, previousBrush);
+ if (previousPen) SelectObject(hdc, previousPen);
+ if (saved) RestoreDC(hdc, saved);
+ if (pen) DeleteObject(pen);
+ const GdiState stateAfter = captureGdiState(hdc);
+ const bool gdiStateRestored = saved != 0 && sameGdiState(stateBefore, stateAfter);
+
+ const SurfaceFingerprint afterDraw = captureSurfaceFingerprint(hdc, CanvasWidth, CanvasHeight);
+ const RECT easyXMarkerRegion{ 90, 90, 250, 190 };
+ const RECT gdiMarkerRegion{ 490, 90, 650, 190 };
+ const RECT easyXTextRegion{ 90, 240, 400, 310 };
+ const RECT gdiTextRegion{ 490, 240, 800, 310 };
+ const PixelBounds easyXMarkerSurface = findColorBounds(hdc, easyXMarkerRegion, EasyXColor);
+ const PixelBounds gdiMarkerSurface = findColorBounds(hdc, gdiMarkerRegion, GdiColor);
+ const PixelBounds easyXTextSurface = findChangedBounds(hdc, easyXTextRegion, Background);
+ const PixelBounds gdiTextSurface = findChangedBounds(hdc, gdiTextRegion, Background);
+ evidenceComplete &= addSurfaceEvidence(options, hdc, "dpi_after_draw_surface.png",
+ "DPI surface after EasyX and raw GDI drawing", evidence);
+ evidenceComplete &= addClientEvidence(options, hwnd, "dpi_before_flush_client.png",
+ "DPI client before FlushBatchDraw", evidence);
+
+ FlushBatchDraw();
+ evidenceComplete &= addClientEvidence(options, hwnd, "dpi_after_flush_client.png",
+ "DPI client after FlushBatchDraw", evidence);
+ EndBatchDraw();
+ evidenceComplete &= addClientEvidence(options, hwnd, "dpi_after_end_client.png",
+ "DPI client after EndBatchDraw", evidence);
+
+ PixelBounds easyXMarkerClient;
+ PixelBounds gdiMarkerClient;
+ HDC clientDc = GetDC(hwnd);
+ if (clientDc)
+ {
+ easyXMarkerClient = findColorBounds(clientDc, easyXMarkerRegion, EasyXColor);
+ gdiMarkerClient = findColorBounds(clientDc, gdiMarkerRegion, GdiColor);
+ ReleaseDC(hwnd, clientDc);
+ }
+
+ const std::array anchorPoints{
+ POINT{ 0, 0 }, POINT{ 100, 100 }, POINT{ 500, 250 },
+ POINT{ CanvasWidth - 1, CanvasHeight - 1 }
+ };
+ std::vector roundTrips;
+ bool roundTripsExact = true;
+ for (const POINT point : anchorPoints)
+ {
+ roundTrips.push_back(roundTrip(hwnd, point));
+ const auto& result = roundTrips.back();
+ roundTripsExact = roundTripsExact && result.converted &&
+ result.errorX == 0 && result.errorY == 0;
+ }
+
+ const bool surfaceMarkerSizesAgree = easyXMarkerSurface.present && gdiMarkerSurface.present &&
+ std::abs(boundsWidth(easyXMarkerSurface) - boundsWidth(gdiMarkerSurface)) <= 2 &&
+ std::abs(boundsHeight(easyXMarkerSurface) - boundsHeight(gdiMarkerSurface)) <= 2;
+ const bool clientMatchesSurface = easyXMarkerClient.present && gdiMarkerClient.present &&
+ std::abs(boundsWidth(easyXMarkerClient) - boundsWidth(easyXMarkerSurface)) <= 1 &&
+ std::abs(boundsHeight(easyXMarkerClient) - boundsHeight(easyXMarkerSurface)) <= 1 &&
+ std::abs(boundsWidth(gdiMarkerClient) - boundsWidth(gdiMarkerSurface)) <= 1 &&
+ std::abs(boundsHeight(gdiMarkerClient) - boundsHeight(gdiMarkerSurface)) <= 1;
+ const bool implicitScalingObserved = !surfaceMarkerSizesAgree || !clientMatchesSurface;
+ const bool completed = evidenceComplete && gdiStateRestored && gdiExtentSuccess &&
+ gdiTextSuccess && easyXMarkerSurface.present && gdiMarkerSurface.present &&
+ easyXTextSurface.present && gdiTextSurface.present && roundTripsExact;
+
+ std::ostringstream result;
+ result << "{\"batchState\":\"inside\",\"surfaceFingerprintAfterDraw\":";
+ writeSurfaceFingerprintJson(result, afterDraw);
+ result << ",\"gdiStateRestored\":";
+ writeBoolean(result, gdiStateRestored);
+ result << ",\"easyXLogicalExtent\":";
+ writeSizeJson(result, easyXLogicalExtent);
+ result << ",\"gdiLogicalExtent\":";
+ writeSizeJson(result, gdiLogicalExtent);
+ result << ",\"gdiExtentSuccess\":";
+ writeBoolean(result, gdiExtentSuccess);
+ result << ",\"gdiTextSuccess\":";
+ writeBoolean(result, gdiTextSuccess);
+ result << ",\"surfaceBounds\":{\"easyXMarker\":";
+ writeBoundsJson(result, easyXMarkerSurface);
+ result << ",\"gdiMarker\":";
+ writeBoundsJson(result, gdiMarkerSurface);
+ result << ",\"easyXText\":";
+ writeBoundsJson(result, easyXTextSurface);
+ result << ",\"gdiText\":";
+ writeBoundsJson(result, gdiTextSurface);
+ result << "},\"clientBounds\":{\"easyXMarker\":";
+ writeBoundsJson(result, easyXMarkerClient);
+ result << ",\"gdiMarker\":";
+ writeBoundsJson(result, gdiMarkerClient);
+ result << "},\"surfaceMarkerSizesAgree\":";
+ writeBoolean(result, surfaceMarkerSizesAgree);
+ result << ",\"clientMatchesSurface\":";
+ writeBoolean(result, clientMatchesSurface);
+ result << ",\"implicitScalingObserved\":";
+ writeBoolean(result, implicitScalingObserved);
+ result << ",\"roundTripsExact\":";
+ writeBoolean(result, roundTripsExact);
+ result << ",\"coordinateRoundTrips\":[";
+ for (std::size_t index = 0; index < roundTrips.size(); ++index)
+ {
+ if (index) result << ',';
+ writeRoundTrip(result, roundTrips[index]);
+ }
+ result << "]}";
+ SetWorkingImage(nullptr);
+ closegraph();
+
+ std::string error;
+ if (!writeRawResult(options, dpiConfiguration, environment, completed,
+ evidence, result.str(), error))
+ {
+ std::cerr << error << '\n';
+ return 4;
+ }
+ return completed ? 0 : 5;
+ }
+}
diff --git a/tests/visual-probe/ImePositionProbe.cpp b/tests/visual-probe/ImePositionProbe.cpp
new file mode 100644
index 0000000..0fffbbd
--- /dev/null
+++ b/tests/visual-probe/ImePositionProbe.cpp
@@ -0,0 +1,436 @@
+#include "VisualProbeCommon.h"
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#pragma comment(lib, "Imm32.lib")
+
+namespace VisualProbe
+{
+ namespace
+ {
+ struct Anchor
+ {
+ const char* id;
+ POINT controlOrigin;
+ POINT localCaretTopLeft;
+ POINT localCaretBottom;
+ RECT localLineRect;
+ };
+
+ struct ImePositionEvent
+ {
+ std::uint64_t sequence = 0;
+ std::string stage;
+ std::string positionTrigger = "none";
+ std::string anchorId;
+ DWORD threadId = 0;
+ std::uintptr_t hkl = 0;
+ std::wstring imeDescription;
+ std::wstring imeFileName;
+ bool apiAttempted = false;
+ bool compositionActiveAtAttempt = false;
+ bool contextAvailable = false;
+ bool compositionSucceeded = false;
+ bool candidateSucceeded = false;
+ POINT hwndRelativePoint{};
+ RECT hwndRelativeLineRect{};
+ POINT screenPoint{};
+ };
+
+ struct ImePositionState
+ {
+ HWND hwnd = nullptr;
+ WNDPROC previousWndProc = nullptr;
+ std::atomic activeAnchor{ 1 };
+ std::atomic compositionActive{ false };
+ std::atomic closeRequested{ false };
+ std::atomic nextSequence{ 0 };
+ std::mutex eventsMutex;
+ std::vector events;
+ };
+
+ constexpr std::array Anchors{
+ Anchor{ "anchor_top_left_safe", POINT{ 80, 125 }, POINT{ 30, 35 }, POINT{ 30, 61 }, RECT{ 10, 30, 250, 66 } },
+ Anchor{ "anchor_center", POINT{ 350, 305 }, POINT{ 130, 55 }, POINT{ 130, 81 }, RECT{ 10, 50, 260, 86 } },
+ Anchor{ "anchor_bottom_right_safe", POINT{ 650, 520 }, POINT{ 180, 45 }, POINT{ 180, 71 }, RECT{ 10, 40, 230, 76 } }
+ };
+
+ ImePositionState* activeState = nullptr;
+
+ POINT surfaceCaret(const Anchor& anchor) noexcept
+ {
+ return POINT{
+ anchor.controlOrigin.x + anchor.localCaretBottom.x,
+ anchor.controlOrigin.y + anchor.localCaretBottom.y
+ };
+ }
+
+ std::wstring imeDescription(HKL hkl)
+ {
+ const UINT length = ImmGetDescriptionW(hkl, nullptr, 0);
+ if (!length) return {};
+ std::wstring value(length + 1, L'\0');
+ const UINT written = ImmGetDescriptionW(hkl, value.data(), static_cast(value.size()));
+ value.resize(written);
+ return value;
+ }
+
+ std::wstring imeFileName(HKL hkl)
+ {
+ const UINT length = ImmGetIMEFileNameW(hkl, nullptr, 0);
+ if (!length) return {};
+ std::wstring value(length + 1, L'\0');
+ const UINT written = ImmGetIMEFileNameW(hkl, value.data(), static_cast(value.size()));
+ value.resize(written);
+ return value;
+ }
+
+ void appendEvent(ImePositionState& state, ImePositionEvent event)
+ {
+ event.sequence = state.nextSequence.fetch_add(1, std::memory_order_relaxed) + 1;
+ std::lock_guard lock(state.eventsMutex);
+ state.events.push_back(std::move(event));
+ }
+
+ void recordLifecycle(ImePositionState& state, const char* stage)
+ {
+ const int anchorIndex = state.activeAnchor.load(std::memory_order_acquire);
+ ImePositionEvent event;
+ event.stage = stage;
+ event.anchorId = Anchors[static_cast(anchorIndex)].id;
+ event.threadId = GetCurrentThreadId();
+ const HKL hkl = GetKeyboardLayout(0);
+ event.hkl = reinterpret_cast(hkl);
+ event.imeDescription = imeDescription(hkl);
+ event.imeFileName = imeFileName(hkl);
+ appendEvent(state, std::move(event));
+ }
+
+ void configureImePosition(ImePositionState& state, const char* stage, const char* positionTrigger)
+ {
+ const int anchorIndex = state.activeAnchor.load(std::memory_order_acquire);
+ const Anchor& anchor = Anchors[static_cast(anchorIndex)];
+ ImePositionEvent event;
+ event.stage = stage;
+ event.positionTrigger = positionTrigger;
+ event.anchorId = anchor.id;
+ event.threadId = GetCurrentThreadId();
+ const HKL hkl = GetKeyboardLayout(0);
+ event.hkl = reinterpret_cast(hkl);
+ event.imeDescription = imeDescription(hkl);
+ event.imeFileName = imeFileName(hkl);
+ event.apiAttempted = true;
+ event.compositionActiveAtAttempt = state.compositionActive.load(std::memory_order_acquire);
+ event.hwndRelativePoint = surfaceCaret(anchor);
+ event.hwndRelativeLineRect = RECT{
+ anchor.controlOrigin.x + anchor.localLineRect.left,
+ anchor.controlOrigin.y + anchor.localLineRect.top,
+ anchor.controlOrigin.x + anchor.localLineRect.right,
+ anchor.controlOrigin.y + anchor.localLineRect.bottom
+ };
+ event.screenPoint = event.hwndRelativePoint;
+ ClientToScreen(state.hwnd, &event.screenPoint);
+
+ HIMC context = ImmGetContext(state.hwnd);
+ event.contextAvailable = context != 0;
+ if (context)
+ {
+ COMPOSITIONFORM composition{};
+ composition.dwStyle = CFS_POINT;
+ composition.ptCurrentPos = event.hwndRelativePoint;
+ event.compositionSucceeded = ImmSetCompositionWindow(context, &composition) != FALSE;
+
+ CANDIDATEFORM candidate{};
+ candidate.dwIndex = 0;
+ candidate.dwStyle = CFS_EXCLUDE;
+ candidate.ptCurrentPos = event.hwndRelativePoint;
+ candidate.rcArea = event.hwndRelativeLineRect;
+ event.candidateSucceeded = ImmSetCandidateWindow(context, &candidate) != FALSE;
+ ImmReleaseContext(state.hwnd, context);
+ }
+ appendEvent(state, std::move(event));
+ }
+
+ LRESULT CALLBACK ProbeWndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
+ {
+ ImePositionState* state = activeState;
+ if (!state)
+ return DefWindowProcA(hwnd, message, wParam, lParam);
+
+ if (message == WM_KEYDOWN && wParam >= VK_F1 && wParam <= VK_F3)
+ {
+ state->activeAnchor.store(static_cast(wParam - VK_F1), std::memory_order_release);
+ configureImePosition(*state, "anchor_preconfigure", "anchor_change");
+ return 0;
+ }
+ else if (message == WM_IME_STARTCOMPOSITION)
+ {
+ state->compositionActive.store(true, std::memory_order_release);
+ configureImePosition(*state, "ime_start", "ime_lifecycle");
+ }
+ else if (message == WM_IME_COMPOSITION)
+ {
+ configureImePosition(*state, "ime_composition", "ime_lifecycle");
+ }
+ else if (message == WM_IME_ENDCOMPOSITION)
+ {
+ recordLifecycle(*state, "ime_end");
+ state->compositionActive.store(false, std::memory_order_release);
+ }
+ else if (message == WM_INPUTLANGCHANGE)
+ {
+ recordLifecycle(*state, "input_language_change");
+ }
+ else if (message == WM_CLOSE)
+ {
+ recordLifecycle(*state, "close_requested");
+ state->closeRequested.store(true, std::memory_order_release);
+ return 0;
+ }
+
+ return state->previousWndProc
+ ? CallWindowProcA(state->previousWndProc, hwnd, message, wParam, lParam)
+ : DefWindowProcA(hwnd, message, wParam, lParam);
+ }
+
+ void drawAnchorScreen(int activeAnchorIndex)
+ {
+ SetWorkingImage(nullptr);
+ BeginBatchDraw();
+ setbkcolor(RGB(238, 241, 246));
+ cleardevice();
+ setbkmode(TRANSPARENT);
+ settextstyle(22, 0, "Microsoft YaHei UI");
+ settextcolor(RGB(30, 45, 70));
+ outtextxy(30, 25, "IME position probe: F1/F2/F3 select anchor; close window when done");
+ outtextxy(30, 58, "Use only fixed non-sensitive input: ceshi; inspect candidate position; Esc cancels");
+ for (std::size_t index = 0; index < Anchors.size(); ++index)
+ {
+ const Anchor& anchor = Anchors[index];
+ const bool selected = static_cast(index) == activeAnchorIndex;
+ const COLORREF color = selected ? RGB(220, 60, 45) : RGB(45, 125, 190);
+ const POINT caret = surfaceCaret(anchor);
+ setlinecolor(color);
+ setlinestyle(PS_SOLID, selected ? 3 : 1);
+ rectangle(anchor.controlOrigin.x, anchor.controlOrigin.y,
+ anchor.controlOrigin.x + anchor.localLineRect.right,
+ anchor.controlOrigin.y + anchor.localLineRect.bottom + 12);
+ line(caret.x, caret.y - 26, caret.x, caret.y + 4);
+ line(caret.x - 5, caret.y, caret.x + 5, caret.y);
+ settextcolor(color);
+ const std::string label = std::string("F") + std::to_string(index + 1) + " " + anchor.id;
+ outtextxy(anchor.controlOrigin.x + 8, anchor.controlOrigin.y + 5, label.c_str());
+ }
+ EndBatchDraw();
+ }
+
+ std::string normalizedRelative(const std::filesystem::path& path)
+ {
+ std::string value = path.generic_string();
+ std::replace(value.begin(), value.end(), '\\', '/');
+ return value;
+ }
+
+ void writeAnchor(std::ostream& output, const Anchor& anchor)
+ {
+ const POINT surfaceTopLeft{
+ anchor.controlOrigin.x + anchor.localCaretTopLeft.x,
+ anchor.controlOrigin.y + anchor.localCaretTopLeft.y
+ };
+ const POINT surfaceBottom = surfaceCaret(anchor);
+ RECT surfaceLine{
+ anchor.controlOrigin.x + anchor.localLineRect.left,
+ anchor.controlOrigin.y + anchor.localLineRect.top,
+ anchor.controlOrigin.x + anchor.localLineRect.right,
+ anchor.controlOrigin.y + anchor.localLineRect.bottom
+ };
+ output << "{\"syntheticAnchorId\":" << jsonString(anchor.id)
+ << ",\"controlOrigin\":";
+ writePointJson(output, anchor.controlOrigin);
+ output << ",\"controlLocalCaretTopLeft\":";
+ writePointJson(output, anchor.localCaretTopLeft);
+ output << ",\"controlLocalCaretBottom\":";
+ writePointJson(output, anchor.localCaretBottom);
+ output << ",\"controlLocalLineRect\":";
+ writeRectJson(output, anchor.localLineRect);
+ output << ",\"easyxSurfaceCaretTopLeft\":";
+ writePointJson(output, surfaceTopLeft);
+ output << ",\"easyxSurfaceCaretBottom\":";
+ writePointJson(output, surfaceBottom);
+ output << ",\"easyxSurfaceLineRect\":";
+ writeRectJson(output, surfaceLine);
+ output << ",\"targetHwndRelativePoint\":";
+ writePointJson(output, surfaceBottom);
+ output << '}';
+ }
+
+ void writeEvent(std::ostream& output, const ImePositionEvent& event)
+ {
+ output << "{\"sequence\":" << event.sequence
+ << ",\"stage\":" << jsonString(event.stage)
+ << ",\"positionTrigger\":" << jsonString(event.positionTrigger)
+ << ",\"syntheticAnchorId\":" << jsonString(event.anchorId)
+ << ",\"threadId\":" << event.threadId
+ << ",\"hkl\":" << jsonString(hexValue(event.hkl))
+ << ",\"imeDescriptionUtf16\":" << utf16UnitsJson(event.imeDescription)
+ << ",\"imeFileNameUtf16\":" << utf16UnitsJson(event.imeFileName)
+ << ",\"apiAttempted\":";
+ writeBoolean(output, event.apiAttempted);
+ output << ",\"compositionActiveAtAttempt\":";
+ writeBoolean(output, event.compositionActiveAtAttempt);
+ output << ",\"contextAvailable\":";
+ writeBoolean(output, event.contextAvailable);
+ output << ",\"compositionForm\":{\"style\":" << CFS_POINT
+ << ",\"apiResult\":";
+ writeBoolean(output, event.compositionSucceeded);
+ output << "},\"candidateForm\":{\"style\":" << CFS_EXCLUDE
+ << ",\"index\":0,\"apiResult\":";
+ writeBoolean(output, event.candidateSucceeded);
+ output << ",\"excludeRect\":";
+ writeRectJson(output, event.hwndRelativeLineRect);
+ output << "},\"targetHwndRelativePoint\":";
+ writePointJson(output, event.hwndRelativePoint);
+ output << ",\"diagnosticScreenPoint\":";
+ writePointJson(output, event.screenPoint);
+ output << '}';
+ }
+ }
+
+ int runImePositionProbe(const Options& options, const DpiConfiguration& dpiConfiguration)
+ {
+ HWND hwnd = initgraph(CanvasWidth, CanvasHeight, EX_SHOWCONSOLE);
+ if (!hwnd)
+ {
+ std::cerr << "initgraph failed\n";
+ return 3;
+ }
+ SetWindowTextA(hwnd, "StellarX 0C IME Position Probe");
+ ShowWindow(hwnd, SW_SHOWNORMAL);
+ SetForegroundWindow(hwnd);
+ Sleep(80);
+ const EnvironmentSnapshot environment = captureEnvironment(hwnd);
+ DWORD windowProcessId = 0;
+ const DWORD windowOwnerThread = GetWindowThreadProcessId(hwnd, &windowProcessId);
+
+ ImePositionState state;
+ state.hwnd = hwnd;
+ activeState = &state;
+ SetLastError(ERROR_SUCCESS);
+ state.previousWndProc = reinterpret_cast(SetWindowLongPtrA(
+ hwnd, GWLP_WNDPROC, reinterpret_cast(&ProbeWndProc)));
+ const DWORD subclassError = state.previousWndProc ? ERROR_SUCCESS : GetLastError();
+
+ std::vector evidence;
+ bool evidenceComplete = state.previousWndProc != nullptr;
+ for (std::size_t index = 0; index < Anchors.size(); ++index)
+ {
+ drawAnchorScreen(static_cast(index));
+ Sleep(40);
+ const auto relative = std::filesystem::path("evidence") /
+ (std::string(Anchors[index].id) + "_client.png");
+ std::string backend;
+ const bool saved = captureClientImage(hwnd, options.outputDirectory / relative, &backend);
+ evidenceComplete = evidenceComplete && saved;
+ if (saved)
+ evidence.push_back({ normalizedRelative(relative), "synthetic anchor client evidence", backend });
+ }
+ state.activeAnchor.store(1, std::memory_order_release);
+ drawAnchorScreen(1);
+
+ int lastDrawnAnchor = 1;
+ const ULONGLONG loopStarted = GetTickCount64();
+ while (!state.closeRequested.load(std::memory_order_acquire) && IsWindow(hwnd))
+ {
+ if (options.autoCloseMilliseconds > 0 &&
+ GetTickCount64() - loopStarted >= options.autoCloseMilliseconds)
+ {
+ recordLifecycle(state, "auto_close");
+ state.closeRequested.store(true, std::memory_order_release);
+ break;
+ }
+ const int currentAnchor = state.activeAnchor.load(std::memory_order_acquire);
+ if (currentAnchor != lastDrawnAnchor)
+ {
+ drawAnchorScreen(currentAnchor);
+ lastDrawnAnchor = currentAnchor;
+ }
+ Sleep(16);
+ }
+
+ if (state.previousWndProc && IsWindow(hwnd))
+ SetWindowLongPtrA(hwnd, GWLP_WNDPROC,
+ reinterpret_cast(state.previousWndProc));
+ activeState = nullptr;
+ if (IsWindow(hwnd)) closegraph();
+
+ std::vector events;
+ {
+ std::lock_guard lock(state.eventsMutex);
+ events = state.events;
+ }
+ std::array anchorSucceeded{};
+ bool imeLifecycleObserved = false;
+ bool imeLifecycleSucceeded = false;
+ for (const auto& event : events)
+ {
+ if (event.positionTrigger == "ime_lifecycle") imeLifecycleObserved = true;
+ if (event.positionTrigger == "ime_lifecycle" && event.contextAvailable &&
+ event.compositionSucceeded && event.candidateSucceeded)
+ {
+ imeLifecycleSucceeded = true;
+ }
+ if (!event.apiAttempted || !event.compositionSucceeded || !event.candidateSucceeded)
+ continue;
+ for (std::size_t index = 0; index < Anchors.size(); ++index)
+ {
+ if (event.anchorId == Anchors[index].id) anchorSucceeded[index] = true;
+ }
+ }
+ bool allAnchorsSucceeded = true;
+ for (const bool value : anchorSucceeded) allAnchorsSucceeded = allAnchorsSucceeded && value;
+ const bool completed = evidenceComplete && allAnchorsSucceeded && imeLifecycleSucceeded;
+
+ std::ostringstream result;
+ result << "{\"expectedImeName\":" << jsonString(options.expectedImeName)
+ << ",\"windowOwnerThreadId\":" << windowOwnerThread
+ << ",\"windowProcessId\":" << windowProcessId
+ << ",\"subclassInstalled\":";
+ writeBoolean(result, state.previousWndProc != nullptr);
+ result << ",\"subclassError\":" << subclassError
+ << ",\"allAnchorsApiSucceeded\":";
+ writeBoolean(result, allAnchorsSucceeded);
+ result << ",\"imeLifecycleObserved\":";
+ writeBoolean(result, imeLifecycleObserved);
+ result << ",\"imeLifecycleSucceeded\":";
+ writeBoolean(result, imeLifecycleSucceeded);
+ result << ",\"anchors\":[";
+ for (std::size_t index = 0; index < Anchors.size(); ++index)
+ {
+ if (index) result << ',';
+ writeAnchor(result, Anchors[index]);
+ }
+ result << "],\"imeEvents\":[";
+ for (std::size_t index = 0; index < events.size(); ++index)
+ {
+ if (index) result << ',';
+ writeEvent(result, events[index]);
+ }
+ result << "]}";
+
+ std::string error;
+ if (!writeRawResult(options, dpiConfiguration, environment, completed,
+ evidence, result.str(), error))
+ {
+ std::cerr << error << '\n';
+ return 4;
+ }
+ return completed ? 0 : 5;
+ }
+}
diff --git a/tests/visual-probe/VisualProbeCommon.cpp b/tests/visual-probe/VisualProbeCommon.cpp
new file mode 100644
index 0000000..cf5cebd
--- /dev/null
+++ b/tests/visual-probe/VisualProbeCommon.cpp
@@ -0,0 +1,722 @@
+#include "VisualProbeCommon.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace VisualProbe
+{
+ namespace
+ {
+ using SetProcessDpiAwarenessContextFn = BOOL(WINAPI*)(DPI_AWARENESS_CONTEXT);
+ using GetDpiForWindowFn = UINT(WINAPI*)(HWND);
+ using GetThreadDpiAwarenessContextFn = DPI_AWARENESS_CONTEXT(WINAPI*)();
+ using GetWindowDpiAwarenessContextFn = DPI_AWARENESS_CONTEXT(WINAPI*)(HWND);
+ using GetAwarenessFromDpiAwarenessContextFn = DPI_AWARENESS(WINAPI*)(DPI_AWARENESS_CONTEXT);
+ using GetProcessDpiAwarenessFn = HRESULT(WINAPI*)(HANDLE, int*);
+
+ std::string awarenessName(int awareness)
+ {
+ switch (awareness)
+ {
+ case 0: return "unaware";
+ case 1: return "system";
+ case 2: return "per-monitor";
+ default: return "unknown";
+ }
+ }
+
+ std::string contextAwarenessName(DPI_AWARENESS_CONTEXT context) noexcept
+ {
+ HMODULE user32 = GetModuleHandleW(L"user32.dll");
+ const auto getAwareness = user32 ? reinterpret_cast(
+ GetProcAddress(user32, "GetAwarenessFromDpiAwarenessContext")) : nullptr;
+ if (!getAwareness || !context)
+ return "not_available";
+ return awarenessName(static_cast(getAwareness(context)));
+ }
+
+ DPI_AWARENESS_CONTEXT requestedContext(const std::string& requested) noexcept
+ {
+ if (requested == "unaware") return DPI_AWARENESS_CONTEXT_UNAWARE;
+ if (requested == "system") return DPI_AWARENESS_CONTEXT_SYSTEM_AWARE;
+ if (requested == "per-monitor") return DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE;
+ if (requested == "per-monitor-v2") return DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2;
+ return nullptr;
+ }
+
+ std::uint64_t fnv1a(const std::vector& pixels) noexcept
+ {
+ std::uint64_t value = 1469598103934665603ull;
+ for (const std::uint32_t pixel : pixels)
+ {
+ for (unsigned int shift = 0; shift < 32; shift += 8)
+ {
+ value ^= static_cast((pixel >> shift) & 0xFFu);
+ value *= 1099511628211ull;
+ }
+ }
+ return value;
+ }
+
+ std::vector readPixels(
+ HDC hdc,
+ int width,
+ int height,
+ int sourceX = 0,
+ int sourceY = 0) noexcept
+ {
+ std::vector pixels;
+ if (!hdc || width <= 0 || height <= 0)
+ return pixels;
+ BITMAPINFO info{};
+ info.bmiHeader.biSize = sizeof(info.bmiHeader);
+ info.bmiHeader.biWidth = width;
+ info.bmiHeader.biHeight = -height;
+ info.bmiHeader.biPlanes = 1;
+ info.bmiHeader.biBitCount = 32;
+ info.bmiHeader.biCompression = BI_RGB;
+ void* bits = nullptr;
+ HDC memoryDc = CreateCompatibleDC(hdc);
+ if (!memoryDc)
+ return pixels;
+ HBITMAP bitmap = CreateDIBSection(hdc, &info, DIB_RGB_COLORS, &bits, nullptr, 0);
+ if (!bitmap || !bits)
+ {
+ if (bitmap) DeleteObject(bitmap);
+ DeleteDC(memoryDc);
+ return pixels;
+ }
+ HGDIOBJ previous = SelectObject(memoryDc, bitmap);
+ const bool copied = previous && BitBlt(memoryDc, 0, 0, width, height,
+ hdc, sourceX, sourceY, SRCCOPY) != FALSE;
+ if (copied)
+ {
+ try
+ {
+ const auto* first = static_cast(bits);
+ pixels.assign(first, first + static_cast(width) * height);
+ }
+ catch (...)
+ {
+ pixels.clear();
+ }
+ }
+ if (previous) SelectObject(memoryDc, previous);
+ DeleteObject(bitmap);
+ DeleteDC(memoryDc);
+ return pixels;
+ }
+
+ bool xformEqual(const XFORM& left, const XFORM& right) noexcept
+ {
+ constexpr float Epsilon = 0.0001f;
+ return std::fabs(left.eM11 - right.eM11) < Epsilon &&
+ std::fabs(left.eM12 - right.eM12) < Epsilon &&
+ std::fabs(left.eM21 - right.eM21) < Epsilon &&
+ std::fabs(left.eM22 - right.eM22) < Epsilon &&
+ std::fabs(left.eDx - right.eDx) < Epsilon &&
+ std::fabs(left.eDy - right.eDy) < Epsilon;
+ }
+
+ std::string captureOsBuild() noexcept
+ {
+ using RtlGetVersionFn = LONG(WINAPI*)(OSVERSIONINFOW*);
+ HMODULE ntdll = GetModuleHandleW(L"ntdll.dll");
+ const auto rtlGetVersion = ntdll ? reinterpret_cast(
+ GetProcAddress(ntdll, "RtlGetVersion")) : nullptr;
+ OSVERSIONINFOW version{};
+ version.dwOSVersionInfoSize = sizeof(version);
+ if (!rtlGetVersion || rtlGetVersion(&version) != 0)
+ return "not_available";
+ std::ostringstream output;
+ output << version.dwMajorVersion << '.' << version.dwMinorVersion
+ << '.' << version.dwBuildNumber;
+ return output.str();
+ }
+ }
+
+ bool parseOptions(int argc, char** argv, Options& options, std::string& error)
+ {
+ for (int index = 1; index < argc; ++index)
+ {
+ const std::string argument = argv[index];
+ if ((argument == "--mode" || argument == "--output" || argument == "--dpi-awareness" ||
+ argument == "--ime-name" || argument == "--auto-close-ms") &&
+ index + 1 >= argc)
+ {
+ error = "Missing value for " + argument;
+ return false;
+ }
+ if (argument == "--mode") options.mode = argv[++index];
+ else if (argument == "--output") options.outputDirectory = argv[++index];
+ else if (argument == "--dpi-awareness") options.requestedDpiAwareness = argv[++index];
+ else if (argument == "--ime-name") options.expectedImeName = argv[++index];
+ else if (argument == "--auto-close-ms")
+ {
+ try
+ {
+ options.autoCloseMilliseconds = static_cast(std::stoul(argv[++index]));
+ }
+ catch (...)
+ {
+ error = "Invalid auto-close duration";
+ return false;
+ }
+ }
+ else
+ {
+ error = "Unknown argument: " + argument;
+ return false;
+ }
+ }
+ if (options.mode != "wide-text" && options.mode != "dpi-coordinate" &&
+ options.mode != "ime-position")
+ {
+ error = "Mode must be wide-text, dpi-coordinate, or ime-position";
+ return false;
+ }
+ if (options.outputDirectory.empty())
+ {
+ error = "Output directory is required";
+ return false;
+ }
+ const std::vector awarenessValues{
+ "inherit", "unaware", "system", "per-monitor", "per-monitor-v2"
+ };
+ if (std::find(awarenessValues.begin(), awarenessValues.end(), options.requestedDpiAwareness) ==
+ awarenessValues.end())
+ {
+ error = "Unsupported DPI awareness: " + options.requestedDpiAwareness;
+ return false;
+ }
+ const std::vector imeNames{ "unspecified", "MicrosoftPinyin", "Sogou" };
+ if (std::find(imeNames.begin(), imeNames.end(), options.expectedImeName) == imeNames.end())
+ {
+ error = "Unsupported IME name: " + options.expectedImeName;
+ return false;
+ }
+ std::error_code fileError;
+ std::filesystem::create_directories(options.outputDirectory / "evidence", fileError);
+ if (fileError)
+ {
+ error = "Cannot create output directory: " + fileError.message();
+ return false;
+ }
+ return true;
+ }
+
+ DpiConfiguration applyRequestedDpiAwareness(const std::string& requested) noexcept
+ {
+ DpiConfiguration result;
+ result.requested = requested;
+ if (requested == "inherit")
+ {
+ result.applied = true;
+ return result;
+ }
+ result.attempted = true;
+ HMODULE user32 = GetModuleHandleW(L"user32.dll");
+ const auto setContext = user32 ? reinterpret_cast(
+ GetProcAddress(user32, "SetProcessDpiAwarenessContext")) : nullptr;
+ if (!setContext)
+ {
+ result.error = ERROR_PROC_NOT_FOUND;
+ return result;
+ }
+ SetLastError(ERROR_SUCCESS);
+ result.applied = setContext(requestedContext(requested)) != FALSE;
+ result.error = result.applied ? ERROR_SUCCESS : GetLastError();
+ return result;
+ }
+
+ EnvironmentSnapshot captureEnvironment(HWND hwnd) noexcept
+ {
+ EnvironmentSnapshot snapshot;
+ snapshot.processId = GetCurrentProcessId();
+ snapshot.threadId = GetCurrentThreadId();
+ snapshot.windowUnicode = hwnd && IsWindowUnicode(hwnd) != FALSE;
+ snapshot.osBuild = captureOsBuild();
+ const TCHAR* easyxVersion = GetEasyXVer();
+ snapshot.easyxVersion = easyxVersion ? easyxVersion : "not_available";
+ if (hwnd)
+ {
+ GetClientRect(hwnd, &snapshot.clientRect);
+ GetWindowRect(hwnd, &snapshot.windowRect);
+ }
+
+ HMODULE user32 = GetModuleHandleW(L"user32.dll");
+ const auto getDpi = user32 ? reinterpret_cast(
+ GetProcAddress(user32, "GetDpiForWindow")) : nullptr;
+ const auto getThreadContext = user32 ? reinterpret_cast(
+ GetProcAddress(user32, "GetThreadDpiAwarenessContext")) : nullptr;
+ const auto getWindowContext = user32 ? reinterpret_cast(
+ GetProcAddress(user32, "GetWindowDpiAwarenessContext")) : nullptr;
+ snapshot.dpiForWindow = getDpi && hwnd ? getDpi(hwnd) : 0;
+ snapshot.threadAwareness = contextAwarenessName(getThreadContext ? getThreadContext() : nullptr);
+ snapshot.windowAwareness = contextAwarenessName(
+ getWindowContext && hwnd ? getWindowContext(hwnd) : nullptr);
+
+ HMODULE shcore = LoadLibraryW(L"shcore.dll");
+ const auto getProcessAwareness = shcore ? reinterpret_cast(
+ GetProcAddress(shcore, "GetProcessDpiAwareness")) : nullptr;
+ int processAwareness = -1;
+ if (getProcessAwareness && SUCCEEDED(getProcessAwareness(nullptr, &processAwareness)))
+ snapshot.processAwareness = awarenessName(processAwareness);
+ else
+ snapshot.processAwareness = "not_available";
+ if (shcore) FreeLibrary(shcore);
+
+ HDC screen = GetDC(nullptr);
+ if (screen)
+ {
+ snapshot.deviceDpiX = GetDeviceCaps(screen, LOGPIXELSX);
+ snapshot.deviceDpiY = GetDeviceCaps(screen, LOGPIXELSY);
+ ReleaseDC(nullptr, screen);
+ }
+ return snapshot;
+ }
+
+ GdiState captureGdiState(HDC hdc) noexcept
+ {
+ GdiState state;
+ if (!hdc) return state;
+ state.mapMode = GetMapMode(hdc);
+ GetViewportOrgEx(hdc, &state.viewportOrigin);
+ GetWindowOrgEx(hdc, &state.windowOrigin);
+ GetViewportExtEx(hdc, &state.viewportExtent);
+ GetWindowExtEx(hdc, &state.windowExtent);
+ state.graphicsMode = GetGraphicsMode(hdc);
+ state.worldTransformAvailable = GetWorldTransform(hdc, &state.worldTransform) != FALSE;
+ state.backgroundMode = GetBkMode(hdc);
+ state.textColor = GetTextColor(hdc);
+ state.backgroundColor = GetBkColor(hdc);
+ state.textAlign = GetTextAlign(hdc);
+ state.textCharacterExtra = GetTextCharacterExtra(hdc);
+ state.clipType = GetClipBox(hdc, &state.clipBox);
+ return state;
+ }
+
+ bool sameGdiState(const GdiState& left, const GdiState& right) noexcept
+ {
+ return left.mapMode == right.mapMode &&
+ left.viewportOrigin.x == right.viewportOrigin.x &&
+ left.viewportOrigin.y == right.viewportOrigin.y &&
+ left.windowOrigin.x == right.windowOrigin.x &&
+ left.windowOrigin.y == right.windowOrigin.y &&
+ left.viewportExtent.cx == right.viewportExtent.cx &&
+ left.viewportExtent.cy == right.viewportExtent.cy &&
+ left.windowExtent.cx == right.windowExtent.cx &&
+ left.windowExtent.cy == right.windowExtent.cy &&
+ left.graphicsMode == right.graphicsMode &&
+ left.worldTransformAvailable == right.worldTransformAvailable &&
+ (!left.worldTransformAvailable || xformEqual(left.worldTransform, right.worldTransform)) &&
+ left.backgroundMode == right.backgroundMode &&
+ left.textColor == right.textColor &&
+ left.backgroundColor == right.backgroundColor &&
+ left.textAlign == right.textAlign &&
+ left.textCharacterExtra == right.textCharacterExtra &&
+ left.clipType == right.clipType &&
+ EqualRect(&left.clipBox, &right.clipBox) != FALSE;
+ }
+
+ SurfaceFingerprint captureSurfaceFingerprint(HDC hdc, int width, int height) noexcept
+ {
+ SurfaceFingerprint fingerprint;
+ fingerprint.hdc = reinterpret_cast(hdc);
+ fingerprint.hbitmap = reinterpret_cast(
+ hdc ? GetCurrentObject(hdc, OBJ_BITMAP) : nullptr);
+ fingerprint.hfont = reinterpret_cast(
+ hdc ? GetCurrentObject(hdc, OBJ_FONT) : nullptr);
+ if (fingerprint.hfont)
+ {
+ fingerprint.logFontAvailable = GetObjectW(
+ reinterpret_cast(fingerprint.hfont),
+ sizeof(fingerprint.logFont), &fingerprint.logFont) == sizeof(fingerprint.logFont);
+ }
+ if (fingerprint.hbitmap)
+ {
+ BITMAP bitmap{};
+ if (GetObject(reinterpret_cast(fingerprint.hbitmap), sizeof(bitmap), &bitmap) ==
+ sizeof(bitmap))
+ {
+ fingerprint.bitmapInfoAvailable = true;
+ fingerprint.bitmapWidth = bitmap.bmWidth;
+ fingerprint.bitmapHeight = bitmap.bmHeight;
+ fingerprint.planes = bitmap.bmPlanes;
+ fingerprint.bitsPerPixel = bitmap.bmBitsPixel;
+ fingerprint.stride = bitmap.bmWidthBytes;
+ }
+ DIBSECTION section{};
+ fingerprint.dibSection = GetObject(reinterpret_cast(fingerprint.hbitmap),
+ sizeof(section), §ion) == sizeof(section);
+ }
+ const auto pixels = readPixels(hdc, width, height);
+ fingerprint.pixelDigest = fnv1a(pixels);
+ fingerprint.gdiState = captureGdiState(hdc);
+ return fingerprint;
+ }
+
+ std::uint64_t capturePixelRegionDigest(HDC hdc, const RECT& region) noexcept
+ {
+ const int width = region.right - region.left;
+ const int height = region.bottom - region.top;
+ return fnv1a(readPixels(hdc, width, height, region.left, region.top));
+ }
+
+ PixelBounds findColorBounds(HDC hdc, const RECT& region, COLORREF color) noexcept
+ {
+ PixelBounds bounds;
+ for (int y = region.top; y < region.bottom; ++y)
+ {
+ for (int x = region.left; x < region.right; ++x)
+ {
+ if (GetPixel(hdc, x, y) != color) continue;
+ if (!bounds.present)
+ {
+ bounds.present = true;
+ bounds.left = bounds.right = x;
+ bounds.top = bounds.bottom = y;
+ }
+ else
+ {
+ bounds.left = (std::min)(bounds.left, x);
+ bounds.top = (std::min)(bounds.top, y);
+ bounds.right = (std::max)(bounds.right, x);
+ bounds.bottom = (std::max)(bounds.bottom, y);
+ }
+ ++bounds.pixelCount;
+ }
+ }
+ return bounds;
+ }
+
+ PixelBounds findChangedBounds(HDC hdc, const RECT& region, COLORREF background) noexcept
+ {
+ PixelBounds bounds;
+ for (int y = region.top; y < region.bottom; ++y)
+ {
+ for (int x = region.left; x < region.right; ++x)
+ {
+ const COLORREF pixel = GetPixel(hdc, x, y);
+ if (pixel == CLR_INVALID || pixel == background) continue;
+ if (!bounds.present)
+ {
+ bounds.present = true;
+ bounds.left = bounds.right = x;
+ bounds.top = bounds.bottom = y;
+ }
+ else
+ {
+ bounds.left = (std::min)(bounds.left, x);
+ bounds.top = (std::min)(bounds.top, y);
+ bounds.right = (std::max)(bounds.right, x);
+ bounds.bottom = (std::max)(bounds.bottom, y);
+ }
+ ++bounds.pixelCount;
+ }
+ }
+ return bounds;
+ }
+
+ bool saveHdcImage(HDC source, int width, int height, const std::filesystem::path& path) noexcept
+ {
+ if (!source || width <= 0 || height <= 0) return false;
+ try
+ {
+ IMAGE image(width, height);
+ HDC destination = GetImageHDC(&image);
+ if (!destination || BitBlt(destination, 0, 0, width, height, source, 0, 0, SRCCOPY) == FALSE)
+ return false;
+ saveimage(path.string().c_str(), &image);
+ return std::filesystem::exists(path) && std::filesystem::file_size(path) > 0;
+ }
+ catch (...)
+ {
+ return false;
+ }
+ }
+
+ bool captureClientImage(
+ HWND hwnd,
+ const std::filesystem::path& path,
+ std::string* captureBackend) noexcept
+ {
+ if (!hwnd) return false;
+ RECT client{};
+ if (!GetClientRect(hwnd, &client)) return false;
+ const int width = client.right - client.left;
+ const int height = client.bottom - client.top;
+ try
+ {
+ IMAGE image(width, height);
+ HDC destination = GetImageHDC(&image);
+ if (!destination) return false;
+ PatBlt(destination, 0, 0, width, height, BLACKNESS);
+ bool captured = false;
+ HDC clientDc = GetDC(hwnd);
+ if (clientDc)
+ {
+ captured = BitBlt(destination, 0, 0, width, height,
+ clientDc, 0, 0, SRCCOPY) != FALSE;
+ ReleaseDC(hwnd, clientDc);
+ }
+ if (captured)
+ {
+ if (captureBackend) *captureBackend = "client_getdc_bitblt";
+ }
+ else
+ {
+ captured = PrintWindow(hwnd, destination, PW_CLIENTONLY) != FALSE;
+ if (captured && captureBackend) *captureBackend = "printwindow_fallback";
+ }
+ if (!captured) return false;
+ saveimage(path.string().c_str(), &image);
+ return std::filesystem::exists(path) && std::filesystem::file_size(path) > 0;
+ }
+ catch (...)
+ {
+ return false;
+ }
+ }
+
+ std::wstring makeUtf16(const std::vector& codePoints, bool& valid)
+ {
+ std::wstring text;
+ valid = true;
+ for (const std::uint32_t codePoint : codePoints)
+ {
+ if (codePoint > 0x10FFFFu || (codePoint >= 0xD800u && codePoint <= 0xDFFFu))
+ {
+ valid = false;
+ continue;
+ }
+ if (codePoint <= 0xFFFFu)
+ text.push_back(static_cast(codePoint));
+ else
+ {
+ const std::uint32_t adjusted = codePoint - 0x10000u;
+ text.push_back(static_cast(0xD800u + (adjusted >> 10)));
+ text.push_back(static_cast(0xDC00u + (adjusted & 0x3FFu)));
+ }
+ }
+ return text;
+ }
+
+ std::string utf16UnitsJson(const std::wstring& text)
+ {
+ std::ostringstream output;
+ output << '[';
+ for (std::size_t index = 0; index < text.size(); ++index)
+ {
+ if (index) output << ',';
+ output << static_cast(static_cast(text[index]));
+ }
+ output << ']';
+ return output.str();
+ }
+
+ std::string codePointsJson(const std::vector& codePoints)
+ {
+ std::ostringstream output;
+ output << '[';
+ for (std::size_t index = 0; index < codePoints.size(); ++index)
+ {
+ if (index) output << ',';
+ output << codePoints[index];
+ }
+ output << ']';
+ return output.str();
+ }
+
+ std::string jsonString(const std::string& value)
+ {
+ std::ostringstream output;
+ output << '"';
+ for (const unsigned char character : value)
+ {
+ switch (character)
+ {
+ case '"': output << "\\\""; break;
+ case '\\': output << "\\\\"; break;
+ case '\b': output << "\\b"; break;
+ case '\f': output << "\\f"; break;
+ case '\n': output << "\\n"; break;
+ case '\r': output << "\\r"; break;
+ case '\t': output << "\\t"; break;
+ default:
+ if (character < 0x20u)
+ output << "\\u" << std::hex << std::setw(4) << std::setfill('0')
+ << static_cast(character) << std::dec;
+ else
+ output << static_cast(character);
+ break;
+ }
+ }
+ output << '"';
+ return output.str();
+ }
+
+ std::string hexValue(std::uint64_t value)
+ {
+ std::ostringstream output;
+ output << "0x" << std::hex << std::uppercase << value;
+ return output.str();
+ }
+
+ void writeBoolean(std::ostream& output, bool value)
+ {
+ output << (value ? "true" : "false");
+ }
+
+ void writeRectJson(std::ostream& output, const RECT& rect)
+ {
+ output << "{\"left\":" << rect.left << ",\"top\":" << rect.top
+ << ",\"right\":" << rect.right << ",\"bottom\":" << rect.bottom << '}';
+ }
+
+ void writePointJson(std::ostream& output, const POINT& point)
+ {
+ output << "{\"x\":" << point.x << ",\"y\":" << point.y << '}';
+ }
+
+ void writeSizeJson(std::ostream& output, const SIZE& size)
+ {
+ output << "{\"cx\":" << size.cx << ",\"cy\":" << size.cy << '}';
+ }
+
+ void writeBoundsJson(std::ostream& output, const PixelBounds& bounds)
+ {
+ output << "{\"present\":";
+ writeBoolean(output, bounds.present);
+ output << ",\"left\":" << bounds.left << ",\"top\":" << bounds.top
+ << ",\"right\":" << bounds.right << ",\"bottom\":" << bounds.bottom
+ << ",\"pixelCount\":" << bounds.pixelCount << '}';
+ }
+
+ void writeGdiStateJson(std::ostream& output, const GdiState& state)
+ {
+ output << "{\"mapMode\":" << state.mapMode << ",\"viewportOrigin\":";
+ writePointJson(output, state.viewportOrigin);
+ output << ",\"windowOrigin\":";
+ writePointJson(output, state.windowOrigin);
+ output << ",\"viewportExtent\":";
+ writeSizeJson(output, state.viewportExtent);
+ output << ",\"windowExtent\":";
+ writeSizeJson(output, state.windowExtent);
+ output << ",\"graphicsMode\":" << state.graphicsMode
+ << ",\"worldTransformAvailable\":";
+ writeBoolean(output, state.worldTransformAvailable);
+ output << ",\"worldTransform\":{\"m11\":" << state.worldTransform.eM11
+ << ",\"m12\":" << state.worldTransform.eM12
+ << ",\"m21\":" << state.worldTransform.eM21
+ << ",\"m22\":" << state.worldTransform.eM22
+ << ",\"dx\":" << state.worldTransform.eDx
+ << ",\"dy\":" << state.worldTransform.eDy << "}"
+ << ",\"backgroundMode\":" << state.backgroundMode
+ << ",\"textColor\":" << state.textColor
+ << ",\"backgroundColor\":" << state.backgroundColor
+ << ",\"textAlign\":" << state.textAlign
+ << ",\"textCharacterExtra\":" << state.textCharacterExtra
+ << ",\"clipType\":" << state.clipType << ",\"clipBox\":";
+ writeRectJson(output, state.clipBox);
+ output << '}';
+ }
+
+ void writeSurfaceFingerprintJson(std::ostream& output, const SurfaceFingerprint& fingerprint)
+ {
+ output << "{\"hdc\":" << jsonString(hexValue(fingerprint.hdc))
+ << ",\"hbitmap\":" << jsonString(hexValue(fingerprint.hbitmap))
+ << ",\"hfont\":" << jsonString(hexValue(fingerprint.hfont))
+ << ",\"bitmapInfoAvailable\":";
+ writeBoolean(output, fingerprint.bitmapInfoAvailable);
+ output << ",\"bitmapWidth\":" << fingerprint.bitmapWidth
+ << ",\"bitmapHeight\":" << fingerprint.bitmapHeight
+ << ",\"planes\":" << fingerprint.planes
+ << ",\"bitsPerPixel\":" << fingerprint.bitsPerPixel
+ << ",\"stride\":" << fingerprint.stride
+ << ",\"dibSection\":";
+ writeBoolean(output, fingerprint.dibSection);
+ output << ",\"logFontAvailable\":";
+ writeBoolean(output, fingerprint.logFontAvailable);
+ output << ",\"logFont\":{"
+ << "\"height\":" << fingerprint.logFont.lfHeight
+ << ",\"width\":" << fingerprint.logFont.lfWidth
+ << ",\"weight\":" << fingerprint.logFont.lfWeight
+ << ",\"italic\":" << static_cast(fingerprint.logFont.lfItalic)
+ << ",\"charset\":" << static_cast(fingerprint.logFont.lfCharSet)
+ << ",\"faceNameUtf16\":[";
+ for (std::size_t index = 0;
+ index < LF_FACESIZE && fingerprint.logFont.lfFaceName[index]; ++index)
+ {
+ if (index) output << ',';
+ output << static_cast(fingerprint.logFont.lfFaceName[index]);
+ }
+ output << "]}";
+ output << ",\"pixelDigest\":" << jsonString(hexValue(fingerprint.pixelDigest))
+ << ",\"gdiState\":";
+ writeGdiStateJson(output, fingerprint.gdiState);
+ output << '}';
+ }
+
+ bool writeRawResult(
+ const Options& options,
+ const DpiConfiguration& dpiConfiguration,
+ const EnvironmentSnapshot& environment,
+ bool completed,
+ const std::vector& evidence,
+ const std::string& resultJson,
+ std::string& error)
+ {
+ const auto path = options.outputDirectory / "raw-result.json";
+ std::ofstream output(path, std::ios::binary | std::ios::trunc);
+ if (!output)
+ {
+ error = "Cannot open raw result: " + path.string();
+ return false;
+ }
+ output << "{\n \"rawSchemaVersion\":1,\n \"mode\":" << jsonString(options.mode)
+ << ",\n \"completed\":";
+ writeBoolean(output, completed);
+ output << ",\n \"dpiConfiguration\":{\"requested\":"
+ << jsonString(dpiConfiguration.requested) << ",\"attempted\":";
+ writeBoolean(output, dpiConfiguration.attempted);
+ output << ",\"applied\":";
+ writeBoolean(output, dpiConfiguration.applied);
+ output << ",\"error\":" << dpiConfiguration.error << "},\n \"environment\":{"
+ << "\"processId\":" << environment.processId
+ << ",\"threadId\":" << environment.threadId
+ << ",\"windowUnicode\":";
+ writeBoolean(output, environment.windowUnicode);
+ output << ",\"dpiForWindow\":" << environment.dpiForWindow
+ << ",\"deviceDpiX\":" << environment.deviceDpiX
+ << ",\"deviceDpiY\":" << environment.deviceDpiY
+ << ",\"processAwareness\":" << jsonString(environment.processAwareness)
+ << ",\"threadAwareness\":" << jsonString(environment.threadAwareness)
+ << ",\"windowAwareness\":" << jsonString(environment.windowAwareness)
+ << ",\"osBuild\":" << jsonString(environment.osBuild)
+ << ",\"easyxVersion\":" << jsonString(environment.easyxVersion)
+ << ",\"clientRect\":";
+ writeRectJson(output, environment.clientRect);
+ output << ",\"windowRect\":";
+ writeRectJson(output, environment.windowRect);
+ output << "},\n \"evidenceFiles\":[";
+ for (std::size_t index = 0; index < evidence.size(); ++index)
+ {
+ if (index) output << ',';
+ output << "{\"path\":" << jsonString(evidence[index].relativePath)
+ << ",\"purpose\":" << jsonString(evidence[index].purpose)
+ << ",\"captureBackend\":" << jsonString(evidence[index].captureBackend) << '}';
+ }
+ output << "],\n \"result\":" << resultJson << "\n}\n";
+ if (!output.good())
+ {
+ error = "Failed writing raw result: " + path.string();
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/tests/visual-probe/VisualProbeCommon.h b/tests/visual-probe/VisualProbeCommon.h
new file mode 100644
index 0000000..4744cc6
--- /dev/null
+++ b/tests/visual-probe/VisualProbeCommon.h
@@ -0,0 +1,143 @@
+#pragma once
+
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+namespace VisualProbe
+{
+ constexpr int CanvasWidth = 960;
+ constexpr int CanvasHeight = 720;
+
+ struct Options
+ {
+ std::string mode;
+ std::filesystem::path outputDirectory;
+ std::string requestedDpiAwareness = "inherit";
+ std::string expectedImeName = "unspecified";
+ unsigned int autoCloseMilliseconds = 0;
+ };
+
+ struct DpiConfiguration
+ {
+ std::string requested = "inherit";
+ bool attempted = false;
+ bool applied = false;
+ DWORD error = ERROR_SUCCESS;
+ };
+
+ struct EvidenceFile
+ {
+ std::string relativePath;
+ std::string purpose;
+ std::string captureBackend;
+ };
+
+ struct PixelBounds
+ {
+ bool present = false;
+ int left = 0;
+ int top = 0;
+ int right = 0;
+ int bottom = 0;
+ std::uint64_t pixelCount = 0;
+ };
+
+ struct GdiState
+ {
+ int mapMode = 0;
+ POINT viewportOrigin{};
+ POINT windowOrigin{};
+ SIZE viewportExtent{};
+ SIZE windowExtent{};
+ int graphicsMode = 0;
+ bool worldTransformAvailable = false;
+ XFORM worldTransform{};
+ int backgroundMode = 0;
+ COLORREF textColor = CLR_INVALID;
+ COLORREF backgroundColor = CLR_INVALID;
+ UINT textAlign = GDI_ERROR;
+ int textCharacterExtra = 0;
+ RECT clipBox{};
+ int clipType = ERROR;
+ };
+
+ struct SurfaceFingerprint
+ {
+ std::uintptr_t hdc = 0;
+ std::uintptr_t hbitmap = 0;
+ std::uintptr_t hfont = 0;
+ int bitmapWidth = 0;
+ int bitmapHeight = 0;
+ int planes = 0;
+ int bitsPerPixel = 0;
+ int stride = 0;
+ bool bitmapInfoAvailable = false;
+ bool dibSection = false;
+ bool logFontAvailable = false;
+ LOGFONTW logFont{};
+ std::uint64_t pixelDigest = 0;
+ GdiState gdiState{};
+ };
+
+ struct EnvironmentSnapshot
+ {
+ DWORD processId = 0;
+ DWORD threadId = 0;
+ bool windowUnicode = false;
+ UINT dpiForWindow = 0;
+ int deviceDpiX = 0;
+ int deviceDpiY = 0;
+ std::string processAwareness;
+ std::string threadAwareness;
+ std::string windowAwareness;
+ std::string osBuild;
+ std::string easyxVersion;
+ RECT clientRect{};
+ RECT windowRect{};
+ };
+
+ bool parseOptions(int argc, char** argv, Options& options, std::string& error);
+ DpiConfiguration applyRequestedDpiAwareness(const std::string& requested) noexcept;
+ EnvironmentSnapshot captureEnvironment(HWND hwnd) noexcept;
+ GdiState captureGdiState(HDC hdc) noexcept;
+ bool sameGdiState(const GdiState& left, const GdiState& right) noexcept;
+ SurfaceFingerprint captureSurfaceFingerprint(HDC hdc, int width, int height) noexcept;
+ std::uint64_t capturePixelRegionDigest(HDC hdc, const RECT& region) noexcept;
+ PixelBounds findColorBounds(HDC hdc, const RECT& region, COLORREF color) noexcept;
+ PixelBounds findChangedBounds(HDC hdc, const RECT& region, COLORREF background) noexcept;
+ bool saveHdcImage(HDC source, int width, int height, const std::filesystem::path& path) noexcept;
+ bool captureClientImage(
+ HWND hwnd,
+ const std::filesystem::path& path,
+ std::string* captureBackend = nullptr) noexcept;
+ std::wstring makeUtf16(const std::vector& codePoints, bool& valid);
+ std::string utf16UnitsJson(const std::wstring& text);
+ std::string codePointsJson(const std::vector& codePoints);
+ std::string jsonString(const std::string& value);
+ std::string hexValue(std::uint64_t value);
+ void writeBoolean(std::ostream& output, bool value);
+ void writeRectJson(std::ostream& output, const RECT& rect);
+ void writePointJson(std::ostream& output, const POINT& point);
+ void writeSizeJson(std::ostream& output, const SIZE& size);
+ void writeBoundsJson(std::ostream& output, const PixelBounds& bounds);
+ void writeGdiStateJson(std::ostream& output, const GdiState& state);
+ void writeSurfaceFingerprintJson(std::ostream& output, const SurfaceFingerprint& fingerprint);
+ bool writeRawResult(
+ const Options& options,
+ const DpiConfiguration& dpiConfiguration,
+ const EnvironmentSnapshot& environment,
+ bool completed,
+ const std::vector& evidence,
+ const std::string& resultJson,
+ std::string& error);
+
+ int runWideTextProbe(const Options& options, const DpiConfiguration& dpiConfiguration);
+ int runDpiCoordinateProbe(const Options& options, const DpiConfiguration& dpiConfiguration);
+ int runImePositionProbe(const Options& options, const DpiConfiguration& dpiConfiguration);
+}
diff --git a/tests/visual-probe/VisualProbeMain.cpp b/tests/visual-probe/VisualProbeMain.cpp
new file mode 100644
index 0000000..c99e706
--- /dev/null
+++ b/tests/visual-probe/VisualProbeMain.cpp
@@ -0,0 +1,29 @@
+#include "VisualProbeCommon.h"
+
+#include
+
+int main(int argc, char** argv)
+{
+ VisualProbe::Options options;
+ std::string error;
+ if (!VisualProbe::parseOptions(argc, argv, options, error))
+ {
+ std::cerr << error << "\n"
+ << "Usage: EasyX.VisualProbe.exe --mode wide-text|dpi-coordinate|ime-position "
+ << "--output [--dpi-awareness inherit|unaware|system|per-monitor|per-monitor-v2] "
+ << "[--ime-name unspecified|MicrosoftPinyin|Sogou] [--auto-close-ms N]\n";
+ return 2;
+ }
+
+ const VisualProbe::DpiConfiguration dpiConfiguration =
+ VisualProbe::applyRequestedDpiAwareness(options.requestedDpiAwareness);
+ if (options.mode == "wide-text")
+ return VisualProbe::runWideTextProbe(options, dpiConfiguration);
+ if (options.mode == "dpi-coordinate")
+ return VisualProbe::runDpiCoordinateProbe(options, dpiConfiguration);
+ if (options.mode == "ime-position")
+ return VisualProbe::runImePositionProbe(options, dpiConfiguration);
+
+ std::cerr << "Unsupported mode: " << options.mode << '\n';
+ return 2;
+}
diff --git a/tests/visual-probe/WideTextProbe.cpp b/tests/visual-probe/WideTextProbe.cpp
new file mode 100644
index 0000000..0e5edf5
--- /dev/null
+++ b/tests/visual-probe/WideTextProbe.cpp
@@ -0,0 +1,434 @@
+#include "VisualProbeCommon.h"
+
+#include
+#include
+#include
+#include
+#include
+
+namespace VisualProbe
+{
+ namespace
+ {
+ constexpr COLORREF MainBackground = RGB(22, 34, 56);
+ constexpr COLORREF WorkingBackground = RGB(64, 28, 46);
+ constexpr COLORREF EasyXMarker = RGB(30, 210, 235);
+ constexpr COLORREF WideTextColor = RGB(246, 224, 64);
+
+ struct TextTest
+ {
+ const char* id;
+ std::vector codePoints;
+ };
+
+ struct TextApiResult
+ {
+ std::string id;
+ std::vector codePoints;
+ std::wstring text;
+ bool validUtf16 = false;
+ bool extentSuccess = false;
+ SIZE extent{};
+ bool textOutSuccess = false;
+ int drawTextHeight = 0;
+ PixelBounds textOutBounds{};
+ PixelBounds drawTextBounds{};
+ };
+
+ struct WideCaseResult
+ {
+ std::string id;
+ std::string surfaceId;
+ std::string batchState;
+ SurfaceFingerprint before{};
+ SurfaceFingerprint afterDraw{};
+ SurfaceFingerprint afterFlush{};
+ SurfaceFingerprint afterEnd{};
+ SurfaceFingerprint afterEasyXFollowUp{};
+ SurfaceFingerprint nonTargetBefore{};
+ SurfaceFingerprint nonTargetAfterDraw{};
+ bool targetChanged = false;
+ bool nonTargetUnchanged = false;
+ bool gdiStateRestored = false;
+ bool flushApplicable = false;
+ bool endApplicable = false;
+ bool explicitWorkingComposition = false;
+ bool wideContentPreservedAfterEasyXFollowUp = false;
+ bool evidenceComplete = true;
+ std::vector textResults;
+ };
+
+ std::vector fixedTests()
+ {
+ return {
+ { "ascii", { 0x0053, 0x0074, 0x0065, 0x006C, 0x006C, 0x0061, 0x0072,
+ 0x0058, 0x0020, 0x0030, 0x0043, 0x0020, 0x0050, 0x0072, 0x006F, 0x0062, 0x0065 } },
+ { "cjk_bmp", { 0x661F, 0x57A3, 0x7ED8, 0x5236, 0x6D4B, 0x8BD5 } },
+ { "supplementary", { 0x1F600 } },
+ { "combining_nfd", { 0x0065, 0x0301 } },
+ { "mixed", { 0x661F, 0x57A3, 0x0020, 0x0041, 0x1F600, 0x0042,
+ 0x0020, 0x0065, 0x0301 } }
+ };
+ }
+
+ void checkpoint(const Options& options, const std::string& value) noexcept
+ {
+ try
+ {
+ std::ofstream output(options.outputDirectory / "progress.log",
+ std::ios::binary | std::ios::app);
+ output << GetTickCount64() << ' ' << value << '\n';
+ output.flush();
+ }
+ catch (...)
+ {
+ }
+ }
+
+ void clearSurface(IMAGE* image, COLORREF background)
+ {
+ SetWorkingImage(image);
+ setbkcolor(background);
+ cleardevice();
+ }
+
+ std::string normalizedRelative(const std::filesystem::path& path)
+ {
+ std::string value = path.generic_string();
+ std::replace(value.begin(), value.end(), '\\', '/');
+ return value;
+ }
+
+ bool addSurfaceEvidence(
+ const Options& options,
+ HDC hdc,
+ const std::string& fileName,
+ const std::string& purpose,
+ std::vector& evidence)
+ {
+ const std::filesystem::path relative = std::filesystem::path("evidence") / fileName;
+ const bool saved = saveHdcImage(hdc, CanvasWidth, CanvasHeight,
+ options.outputDirectory / relative);
+ if (saved)
+ evidence.push_back({ normalizedRelative(relative), purpose, "surface_bitblt" });
+ return saved;
+ }
+
+ bool addClientEvidence(
+ const Options& options,
+ HWND hwnd,
+ const std::string& fileName,
+ const std::string& purpose,
+ std::vector& evidence)
+ {
+ const std::filesystem::path relative = std::filesystem::path("evidence") / fileName;
+ Sleep(30);
+ std::string backend;
+ const bool saved = captureClientImage(hwnd, options.outputDirectory / relative, &backend);
+ if (saved)
+ evidence.push_back({ normalizedRelative(relative), purpose, backend });
+ return saved;
+ }
+
+ void drawEasyXBaseline()
+ {
+ setfillcolor(EasyXMarker);
+ solidrectangle(20, 20, 170, 58);
+ settextcolor(EasyXMarker);
+ setbkmode(TRANSPARENT);
+ settextstyle(22, 0, "Microsoft YaHei UI");
+ outtextxy(20, 68, "EasyX baseline");
+ }
+
+ std::vector drawWideText(HDC hdc, COLORREF background, bool& restored)
+ {
+ std::vector results;
+ const GdiState before = captureGdiState(hdc);
+ const int saved = SaveDC(hdc);
+ HFONT font = CreateFontW(-28, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE,
+ DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY,
+ DEFAULT_PITCH | FF_DONTCARE, L"Microsoft YaHei UI");
+ HGDIOBJ previousFont = font ? SelectObject(hdc, font) : nullptr;
+ SetBkMode(hdc, TRANSPARENT);
+ SetTextColor(hdc, WideTextColor);
+
+ const auto tests = fixedTests();
+ for (std::size_t index = 0; index < tests.size(); ++index)
+ {
+ TextApiResult result;
+ result.id = tests[index].id;
+ result.codePoints = tests[index].codePoints;
+ result.text = makeUtf16(result.codePoints, result.validUtf16);
+ const int y = 120 + static_cast(index) * 105;
+ result.extentSuccess = GetTextExtentPoint32W(hdc, result.text.data(),
+ static_cast(result.text.size()), &result.extent) != FALSE;
+ result.textOutSuccess = TextOutW(hdc, 20, y, result.text.data(),
+ static_cast(result.text.size())) != FALSE;
+ RECT drawRect{ 430, y, 930, y + 70 };
+ result.drawTextHeight = DrawTextW(hdc, result.text.data(),
+ static_cast(result.text.size()), &drawRect,
+ DT_LEFT | DT_TOP | DT_SINGLELINE | DT_NOPREFIX);
+ const RECT textOutRegion{ 15, y - 3, 410, y + 70 };
+ const RECT drawTextRegion{ 425, y - 3, 940, y + 70 };
+ result.textOutBounds = findChangedBounds(hdc, textOutRegion, background);
+ result.drawTextBounds = findChangedBounds(hdc, drawTextRegion, background);
+ results.push_back(std::move(result));
+ }
+
+ if (previousFont) SelectObject(hdc, previousFont);
+ if (saved) RestoreDC(hdc, saved);
+ if (font) DeleteObject(font);
+ const GdiState after = captureGdiState(hdc);
+ restored = saved != 0 && sameGdiState(before, after);
+ return results;
+ }
+
+ WideCaseResult runCase(
+ const Options& options,
+ HWND hwnd,
+ IMAGE& working,
+ bool targetMain,
+ bool insideBatch,
+ std::vector& evidence)
+ {
+ WideCaseResult result;
+ result.surfaceId = targetMain ? "main" : "working";
+ result.batchState = insideBatch ? "inside" : "outside";
+ result.id = result.surfaceId + "_" + result.batchState;
+ result.flushApplicable = insideBatch;
+ result.endApplicable = insideBatch;
+ checkpoint(options, result.id + ":begin");
+
+ clearSurface(nullptr, MainBackground);
+ clearSurface(&working, WorkingBackground);
+ checkpoint(options, result.id + ":surfaces_cleared");
+ IMAGE* targetImage = targetMain ? nullptr : &working;
+ SetWorkingImage(nullptr);
+ HDC mainDc = GetImageHDC(nullptr);
+ HDC workingDc = GetImageHDC(&working);
+ HDC targetDc = targetMain ? mainDc : workingDc;
+ HDC nonTargetDc = targetMain ? workingDc : mainDc;
+ const COLORREF targetBackground = targetMain ? MainBackground : WorkingBackground;
+
+ result.before = captureSurfaceFingerprint(targetDc, CanvasWidth, CanvasHeight);
+ result.nonTargetBefore = captureSurfaceFingerprint(nonTargetDc, CanvasWidth, CanvasHeight);
+ checkpoint(options, result.id + ":before_captured");
+ SetWorkingImage(nullptr);
+ if (insideBatch) BeginBatchDraw();
+ SetWorkingImage(targetImage);
+ drawEasyXBaseline();
+ result.textResults = drawWideText(targetDc, targetBackground, result.gdiStateRestored);
+ checkpoint(options, result.id + ":draw_complete");
+ result.afterDraw = captureSurfaceFingerprint(targetDc, CanvasWidth, CanvasHeight);
+ result.nonTargetAfterDraw = captureSurfaceFingerprint(nonTargetDc, CanvasWidth, CanvasHeight);
+ checkpoint(options, result.id + ":after_draw_captured");
+ result.targetChanged = result.before.pixelDigest != result.afterDraw.pixelDigest;
+ result.nonTargetUnchanged =
+ result.nonTargetBefore.pixelDigest == result.nonTargetAfterDraw.pixelDigest;
+
+ result.evidenceComplete &= addSurfaceEvidence(options, targetDc,
+ result.id + "_after_draw_target.png", result.id + " target after draw", evidence);
+ result.evidenceComplete &= addSurfaceEvidence(options, nonTargetDc,
+ result.id + "_after_draw_non_target.png", result.id + " non-target after draw", evidence);
+ result.evidenceComplete &= addClientEvidence(options, hwnd,
+ result.id + "_after_draw_client.png", result.id + " client after draw", evidence);
+ checkpoint(options, result.id + ":after_draw_evidence");
+
+ if (insideBatch)
+ {
+ SetWorkingImage(nullptr);
+ FlushBatchDraw();
+ result.afterFlush = captureSurfaceFingerprint(targetDc, CanvasWidth, CanvasHeight);
+ result.evidenceComplete &= addClientEvidence(options, hwnd,
+ result.id + "_after_flush_client.png", result.id + " client after flush", evidence);
+ EndBatchDraw();
+ checkpoint(options, result.id + ":batch_ended");
+ result.afterEnd = captureSurfaceFingerprint(targetDc, CanvasWidth, CanvasHeight);
+ result.evidenceComplete &= addClientEvidence(options, hwnd,
+ result.id + "_after_end_client.png", result.id + " client after end", evidence);
+ }
+ else
+ {
+ result.afterFlush = result.afterDraw;
+ result.afterEnd = result.afterDraw;
+ }
+ std::vector protectedRegionDigests;
+ for (std::size_t index = 0; index < result.textResults.size(); ++index)
+ {
+ const int y = 120 + static_cast(index) * 105;
+ const RECT protectedRegion{ 15, y - 3, 940, y + 70 };
+ protectedRegionDigests.push_back(
+ capturePixelRegionDigest(targetDc, protectedRegion));
+ }
+
+ SetWorkingImage(targetImage);
+ setfillcolor(EasyXMarker);
+ solidrectangle(760, 20, 900, 55);
+ result.afterEasyXFollowUp = captureSurfaceFingerprint(
+ targetDc, CanvasWidth, CanvasHeight);
+ result.wideContentPreservedAfterEasyXFollowUp = true;
+ for (std::size_t index = 0; index < result.textResults.size(); ++index)
+ {
+ const int y = 120 + static_cast(index) * 105;
+ const RECT protectedRegion{ 15, y - 3, 940, y + 70 };
+ result.wideContentPreservedAfterEasyXFollowUp =
+ result.wideContentPreservedAfterEasyXFollowUp &&
+ protectedRegionDigests[index] ==
+ capturePixelRegionDigest(targetDc, protectedRegion);
+ }
+ result.evidenceComplete &= addSurfaceEvidence(options, targetDc,
+ result.id + "_after_easyx_follow_up_target.png",
+ result.id + " target after EasyX follow-up drawing", evidence);
+ result.evidenceComplete &= addClientEvidence(options, hwnd,
+ result.id + "_after_easyx_follow_up_client.png",
+ result.id + " client after EasyX follow-up drawing", evidence);
+ checkpoint(options, result.id + ":easyx_follow_up_complete");
+
+ if (!targetMain)
+ {
+ SetWorkingImage(nullptr);
+ BeginBatchDraw();
+ putimage(0, 0, &working);
+ result.evidenceComplete &= addClientEvidence(options, hwnd,
+ result.id + "_composition_before_flush.png",
+ result.id + " explicit putimage before flush", evidence);
+ FlushBatchDraw();
+ result.evidenceComplete &= addClientEvidence(options, hwnd,
+ result.id + "_composition_after_flush.png",
+ result.id + " explicit putimage after flush", evidence);
+ EndBatchDraw();
+ result.evidenceComplete &= addClientEvidence(options, hwnd,
+ result.id + "_composition_after_end.png",
+ result.id + " explicit putimage after end", evidence);
+ result.explicitWorkingComposition = true;
+ }
+ SetWorkingImage(nullptr);
+ checkpoint(options, result.id + ":end");
+ return result;
+ }
+
+ void writeTextResult(std::ostream& output, const TextApiResult& result)
+ {
+ output << "{\"testStringId\":" << jsonString(result.id)
+ << ",\"codePoints\":" << codePointsJson(result.codePoints)
+ << ",\"utf16Units\":" << utf16UnitsJson(result.text)
+ << ",\"isValidUtf16\":";
+ writeBoolean(output, result.validUtf16);
+ output << ",\"getTextExtentPoint32W\":";
+ writeBoolean(output, result.extentSuccess);
+ output << ",\"logicalExtent\":";
+ writeSizeJson(output, result.extent);
+ output << ",\"textOutW\":";
+ writeBoolean(output, result.textOutSuccess);
+ output << ",\"drawTextWHeight\":" << result.drawTextHeight
+ << ",\"textOutInkBounds\":";
+ writeBoundsJson(output, result.textOutBounds);
+ output << ",\"drawTextInkBounds\":";
+ writeBoundsJson(output, result.drawTextBounds);
+ output << '}';
+ }
+
+ void writeCaseResult(std::ostream& output, const WideCaseResult& result)
+ {
+ output << "{\"testCaseId\":" << jsonString(result.id)
+ << ",\"surfaceId\":" << jsonString(result.surfaceId)
+ << ",\"batchState\":" << jsonString(result.batchState)
+ << ",\"targetChanged\":";
+ writeBoolean(output, result.targetChanged);
+ output << ",\"nonTargetUnchanged\":";
+ writeBoolean(output, result.nonTargetUnchanged);
+ output << ",\"gdiStateRestored\":";
+ writeBoolean(output, result.gdiStateRestored);
+ output << ",\"flushApplicable\":";
+ writeBoolean(output, result.flushApplicable);
+ output << ",\"endApplicable\":";
+ writeBoolean(output, result.endApplicable);
+ output << ",\"explicitWorkingComposition\":";
+ writeBoolean(output, result.explicitWorkingComposition);
+ output << ",\"wideContentPreservedAfterEasyXFollowUp\":";
+ writeBoolean(output, result.wideContentPreservedAfterEasyXFollowUp);
+ output << ",\"evidenceComplete\":";
+ writeBoolean(output, result.evidenceComplete);
+ output << ",\"observations\":{\"afterDraw\":";
+ writeSurfaceFingerprintJson(output, result.afterDraw);
+ output << ",\"afterFlush\":";
+ writeSurfaceFingerprintJson(output, result.afterFlush);
+ output << ",\"afterEnd\":";
+ writeSurfaceFingerprintJson(output, result.afterEnd);
+ output << ",\"afterEasyXFollowUp\":";
+ writeSurfaceFingerprintJson(output, result.afterEasyXFollowUp);
+ output << "},\"before\":";
+ writeSurfaceFingerprintJson(output, result.before);
+ output << ",\"nonTargetBefore\":";
+ writeSurfaceFingerprintJson(output, result.nonTargetBefore);
+ output << ",\"nonTargetAfterDraw\":";
+ writeSurfaceFingerprintJson(output, result.nonTargetAfterDraw);
+ output << ",\"textTests\":[";
+ for (std::size_t index = 0; index < result.textResults.size(); ++index)
+ {
+ if (index) output << ',';
+ writeTextResult(output, result.textResults[index]);
+ }
+ output << "]}";
+ }
+ }
+
+ int runWideTextProbe(const Options& options, const DpiConfiguration& dpiConfiguration)
+ {
+ checkpoint(options, "probe:before_initgraph");
+ HWND hwnd = initgraph(CanvasWidth, CanvasHeight, EX_SHOWCONSOLE);
+ if (!hwnd)
+ {
+ std::cerr << "initgraph failed\n";
+ return 3;
+ }
+ checkpoint(options, "probe:after_initgraph");
+ SetWindowTextA(hwnd, "StellarX 0C Wide Text Probe");
+ ShowWindow(hwnd, SW_SHOWNORMAL);
+ SetForegroundWindow(hwnd);
+ Sleep(80);
+ const EnvironmentSnapshot environment = captureEnvironment(hwnd);
+ IMAGE working(CanvasWidth, CanvasHeight);
+ checkpoint(options, "probe:environment_and_working_ready");
+ std::vector evidence;
+ std::vector cases;
+ cases.push_back(runCase(options, hwnd, working, true, false, evidence));
+ cases.push_back(runCase(options, hwnd, working, true, true, evidence));
+ cases.push_back(runCase(options, hwnd, working, false, false, evidence));
+ cases.push_back(runCase(options, hwnd, working, false, true, evidence));
+ checkpoint(options, "probe:all_cases_complete");
+
+ bool completed = true;
+ for (const auto& item : cases)
+ {
+ completed = completed && item.targetChanged && item.nonTargetUnchanged &&
+ item.gdiStateRestored && item.wideContentPreservedAfterEasyXFollowUp &&
+ item.evidenceComplete;
+ for (const auto& text : item.textResults)
+ {
+ completed = completed && text.validUtf16 && text.extentSuccess &&
+ text.textOutSuccess && text.drawTextHeight > 0;
+ }
+ }
+
+ std::ostringstream result;
+ result << "{\"caseCount\":" << cases.size() << ",\"cases\":[";
+ for (std::size_t index = 0; index < cases.size(); ++index)
+ {
+ if (index) result << ',';
+ writeCaseResult(result, cases[index]);
+ }
+ result << "]}";
+ SetWorkingImage(nullptr);
+ closegraph();
+
+ std::string error;
+ if (!writeRawResult(options, dpiConfiguration, environment, completed,
+ evidence, result.str(), error))
+ {
+ std::cerr << error << '\n';
+ return 4;
+ }
+ return completed ? 0 : 5;
+ }
+}